checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.
The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.
`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.
⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.
⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).
THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.
⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.
test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.
main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1504 lines
77 KiB
Python
1504 lines
77 KiB
Python
"""Runnable check for TARIFF-01, the capacity-tariff measurement.
|
|
|
|
python3 test_tariff.py
|
|
|
|
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:
|
|
irregular sample spacing, a sample that STRADDLES the :15 boundary, and a
|
|
gap longer than `max_hold_s` so the block's coverage is short of 900 s.
|
|
2. THE 900 s DEGENERACY, named and then avoided. `acc/elapsed`, `acc/900` and
|
|
the end-of-quarter projection are THREE DIFFERENT FORMULAS that all agree
|
|
at exactly full coverage of a complete block. A check run only there proves
|
|
nothing about any of them, so part 2 first shows them agreeing at that
|
|
point (so the trap is documented rather than asserted around), then shows
|
|
part 1's fixture separating all three - and every later part uses fixtures
|
|
that are away from it.
|
|
3. RESTART MID-BLOCK, through the real `QuarterAverager.snapshot()`/`restore()`
|
|
and a real SQLite file: a restarted run must land on the same closed block
|
|
as an uninterrupted one, and a run that DOES NOT restore must land lower.
|
|
Without that second half, "restore works" is satisfied by a fixture where
|
|
restoring changes nothing.
|
|
4. MONTH ROLLOVER, in LOCAL time, including a restart that straddles it.
|
|
5. AN UNREADABLE STORE IS NOT A ZERO PEAK - the acceptance argument. Three
|
|
states, three different answers, and no accessor that turns "the database
|
|
did not answer" into a number.
|
|
6. THE TIMEZONE FALLBACK, graded against `zoneinfo` day by day.
|
|
7. THE PER-PHASE REDUCTION on an UNBALANCED load. §17's three-phase case. On a
|
|
balanced load net/sum/max all coincide, which is why a balanced fixture is
|
|
worth nothing here.
|
|
8. `monthly_peak` vs a recompute from `quarter_blocks` - the derived record
|
|
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
|
|
(DATA-01), AND IT DOES NOT LAND WHERE ANYONE EXPECTED.
|
|
|
|
AC 3 asks for "the monthly peak held under the soft limit in >= 95 % of
|
|
intervals over a 30-day simulation replay". When this file was written there
|
|
were two days of this house's real meter - 2026-08-20 (the Illusmart failure
|
|
day) and 2026-08-23 (the healthy reference) - exported on 2026-08-25 because
|
|
they were about to age out of HA's recorder.
|
|
|
|
⚠️ AND THE SENTENCE THAT USED TO STAND HERE, "nothing longer was ever
|
|
captured", WAS TRUE ABOUT THE PAST AND WAS READ AS A CLAIM ABOUT THE FUTURE.
|
|
It was not one. HA's recorder still held the whole month; DATA-01 probed it on
|
|
2026-08-31 (230 samples in a 20-minute window at 30 days back, 0 at 45) and
|
|
exported thirty days the same day, into `scenarios/p1-net-30d-2026-08/` with
|
|
`tools/export_p1_history.py` - which is committed now precisely because the
|
|
2026-08-25 one-off was not, and its absence is what let the belief survive.
|
|
|
|
Over those thirty days the criterion measures 98.89 % UNCONTROLLED and 98.99 %
|
|
guarded, so it is met - and that is a finding about the CRITERION as much as
|
|
about the guard, because a month with no controller at all passes it. Read the
|
|
other way - the monthly PEAK itself under the soft limit - it is not met: the
|
|
guard takes August's peak from 5029.8 W to 4107.3 W, still over 3500 W. Both
|
|
numbers, and why they part company, are in
|
|
`scenarios/p1-net-30d-2026-08/README.md`.
|
|
|
|
PART 9 IS DELIBERATELY LEFT GRADING THE TWO COMMITTED DAYS. It is a per-PR
|
|
regression check and the thirty days cost about fifty seconds of replay; they
|
|
also live one directory down precisely so this file's glob does not pick them
|
|
up. `tools/replay_ac3.py` is the thing that runs the month, and it is checked
|
|
against part 6 of test_peakguard.py rather than trusted:
|
|
|
|
python3 tools/replay_ac3.py scenarios --expect 189/190
|
|
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
|
|
|
So part 9 replays the two days that exist, reports the percentage over them, and
|
|
states the sample size in days next to it. It is a MEASUREMENT, not a verdict:
|
|
nothing in it exits non-zero on the 95 % figure, because a 2-day run cannot pass
|
|
or fail a 30-day criterion and a check that claimed it could would be the more
|
|
expensive of the two mistakes. Both days are also *uncontrolled* - the add-on
|
|
was not shaving peaks on either - so the figure they give is the BASELINE the
|
|
guard has to improve on, which is the useful thing a 2-day replay can say.
|
|
|
|
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
|
|
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
|
|
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
|
|
infra repo is private, CI's token is minted for this repository only, and a
|
|
regression fixture that CI cannot see is not a regression fixture.
|
|
|
|
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
|
|
why the committed copy is the artefact and not the API call.
|
|
|
|
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
|
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
|
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
|
edit reverted inside one second is invisible to CPython's staleness test. That
|
|
is TEST-15, and it has already turned one live mutation into a false survivor.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import asyncio
|
|
import gzip
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
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, COUNT_UNREADABLE, NO_RECORD, NOT_OBSERVED,
|
|
POLICY_MAX, POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _ClosedBlock,
|
|
_EUTimezone, billed_w, month_key,
|
|
)
|
|
|
|
fails = []
|
|
HERE = Path(__file__).resolve().parent
|
|
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
|
|
|
|
# ⚠️ NOT config.yaml's shipped max_w (2000). NOTES.md and freeze.py both record
|
|
# that the LIVE INSTALL runs max_w 5000, and that is the number this file uses
|
|
# as the device's power ceiling wherever it needs one - it is named
|
|
# LIVE_INSTALL_MAX_W, not the shorter LIVE_MAX_W, precisely so it cannot be
|
|
# misread as "the current shipped default" (TEST-10; test_freeze.py carries the identical
|
|
# constant, under the identical name, for the identical reason - grep it there
|
|
# for the pin that stops config.yaml's max_w from drifting under it unnoticed).
|
|
# `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3 grades
|
|
# against - read out of the shipped file in part 10 rather than typed.
|
|
LIVE_INSTALL_MAX_W = 5000.0
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def near(a, b, tol=1e-6):
|
|
return a is not None and b is not None and abs(a - b) <= tol
|
|
|
|
|
|
def sample(t: datetime, net_w: float, phases=1, phase_net=None):
|
|
"""One P1 telegram, through the SHIPPED derivation.
|
|
|
|
`split_signed` and `make_sample` are what the real transports call, so the
|
|
sign convention under test is the one that ships rather than a local
|
|
re-derivation of it.
|
|
"""
|
|
imp, exp = split_signed(net_w)
|
|
pi = pe = None
|
|
if phase_net is not None:
|
|
pairs = [split_signed(v) for v in phase_net]
|
|
pi = [a for a, _ in pairs]
|
|
pe = [b for _, b in pairs]
|
|
return make_sample("ha_signed", imp, exp, phases=phases,
|
|
phase_import_w=pi, phase_export_w=pe,
|
|
ingest_ts=t, ingest_mono=t.timestamp())
|
|
|
|
|
|
def fresh_store(name: str) -> PeakStore:
|
|
return PeakStore(str(TMP / f"{name}.db"))
|
|
|
|
|
|
# B is a clock-aligned quarter boundary: proved, not assumed, because every
|
|
# other number in part 1 rests on it.
|
|
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
# ==============================================================================
|
|
print("1. the block arithmetic, on a worked example")
|
|
|
|
check(f"the fixture's start {B.isoformat()} really is 900 s aligned",
|
|
int(B.timestamp()) % QUARTER_S == 0)
|
|
|
|
# (t seconds after B, net W). Deliberately irregular, with an export, a gap
|
|
# longer than max_hold_s, and a last sample that straddles the boundary.
|
|
MAX_HOLD_S = 30.0
|
|
FIXTURE = [(0, 1000.0), (20, 2000.0), (30, -500.0), (50, 3000.0),
|
|
(250, 4000.0), (880, 1200.0), (1000, 900.0)]
|
|
|
|
# The worked example, written as the segments a person would compute by hand:
|
|
# each HELD value and how many seconds of the FIRST block it is evidence for.
|
|
# Two rules do all the work: a sample is held forward until the next one, capped
|
|
# at MAX_HOLD_S; and export is 0 W of offtake, never a negative contribution.
|
|
SEGMENTS = [
|
|
(1000.0, 20), # 0 -> 20, a normal telegram interval, held in full
|
|
(2000.0, 10), # 20 -> 30, likewise
|
|
(0.0, 20), # 30 -> 50: -500 W is EXPORT, so 0 W of offtake for 20 s
|
|
(3000.0, 30), # 50 -> 250 is a 200 s GAP; only the first 30 s is evidence
|
|
(4000.0, 30), # 250 -> 880 is a 630 s gap; again only 30 s is evidence
|
|
(1200.0, 20), # 880 -> 900: the STRADDLE's first half, up to the boundary
|
|
]
|
|
# The straddle's second half: the same held 1200 W, from the boundary until its
|
|
# hold expires at 880 + 30. It must be credited to the NEXT block, not to this
|
|
# one and not to neither.
|
|
SPLIT_TAIL_W, SPLIT_TAIL_S = 1200.0, 10
|
|
EXP_WS = sum(w * s for w, s in SEGMENTS)
|
|
EXP_COVERED_S = sum(s for _, s in SEGMENTS)
|
|
EXP_BLOCK_AVG = EXP_WS / QUARTER_S # what the utility bills
|
|
EXP_ELAPSED_AVG = EXP_WS / EXP_COVERED_S # what a /elapsed divisor gives
|
|
NAIVE_MEAN = sum(max(w, 0.0) for _, w in FIXTURE[:-1]) / (len(FIXTURE) - 1)
|
|
|
|
avg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
closed = []
|
|
for dt_s, w in FIXTURE:
|
|
closed += avg.add(sample(B + timedelta(seconds=dt_s), w))
|
|
|
|
print(f" {len(SEGMENTS)} held segments -> {EXP_WS:,.0f} Ws over "
|
|
f"{EXP_COVERED_S:.0f} s of the {QUARTER_S} s block")
|
|
check(f"exactly one block closed", len(closed) == 1)
|
|
check(f"...starting at the fixture's own boundary {B.isoformat()}",
|
|
bool(closed) and closed[0].start == B)
|
|
check(f"...averaging {EXP_BLOCK_AVG:.2f} W - the worked example's "
|
|
f"{EXP_WS:,.0f} Ws over the FULL {QUARTER_S} s",
|
|
bool(closed) and near(closed[0].offtake_avg_w, EXP_BLOCK_AVG, 1e-9))
|
|
|
|
# The three ways of being wrong, each of which produces a plausible number.
|
|
print(f" the same data read three other ways: /elapsed = {EXP_ELAPSED_AVG:.2f} W, "
|
|
f"naive mean of samples = {NAIVE_MEAN:.2f} W, "
|
|
f"billed = {EXP_BLOCK_AVG:.2f} W")
|
|
check("...and /elapsed is NOT the billed figure - a gap must drag the average "
|
|
"down, not be averaged away",
|
|
abs(EXP_ELAPSED_AVG - EXP_BLOCK_AVG) > 1.0)
|
|
check("...and a naive mean over samples is NOT the billed figure either - it "
|
|
"weights a burst of fast telegrams like a slow one",
|
|
abs(NAIVE_MEAN - EXP_BLOCK_AVG) > 1.0)
|
|
|
|
# The straddle: the 4000 W sample at t=400 is held ACROSS 900. Its evidence
|
|
# expires at 430, so the second block gets none of it - and the second block
|
|
# must still have been OPENED at 900, not at 1000.
|
|
check(f"the block after the straddle opens at the boundary, not at the sample",
|
|
avg.block_start == B + timedelta(seconds=QUARTER_S))
|
|
print(f" the straddling sample is SPLIT at the boundary: "
|
|
f"{SEGMENTS[-1][0]:.0f} W x {SEGMENTS[-1][1]} s into the first block, "
|
|
f"x {SPLIT_TAIL_S} s into the second")
|
|
check(f"...and the second block is credited the tail "
|
|
f"({SPLIT_TAIL_W * SPLIT_TAIL_S:,.0f} Ws), so a sample spanning :15 is "
|
|
f"attributed to BOTH blocks in proportion and wholly to neither",
|
|
near(avg.partial_ws, SPLIT_TAIL_W * SPLIT_TAIL_S, 1e-9))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("2. the 900 s degeneracy - where three different formulas agree")
|
|
|
|
# A complete block at a constant power, fully covered. Here acc/elapsed,
|
|
# acc/900 and the projection are ALL the same number. Anything checked only
|
|
# here is checked against nothing.
|
|
HELD_W = 2400.0
|
|
deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
deg_closed = []
|
|
for i in range(0, QUARTER_S + 1, 5):
|
|
deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W))
|
|
d_block = deg_closed[0].offtake_avg_w
|
|
deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
LAST_S = QUARTER_S - 5
|
|
for i in range(0, LAST_S + 1, 5):
|
|
deg2.add(sample(B + timedelta(seconds=i), HELD_W))
|
|
d_partial = deg2.offtake_avg_w
|
|
# Graded AT the last sample: full coverage behind it, a held value in front.
|
|
d_proj = PeakTracker.project(deg2, B + timedelta(seconds=LAST_S)).projected_w
|
|
print(f" at full coverage of a whole block: acc/900 = {d_block:.2f} W, "
|
|
f"acc/elapsed = {d_partial:.2f} W, projection = {d_proj:.2f} W")
|
|
check("all three formulas coincide at exactly 900 s of full coverage - so a "
|
|
"check placed only there distinguishes none of them",
|
|
near(d_block, HELD_W, 1e-6) and near(d_partial, HELD_W, 1e-6)
|
|
and near(d_proj, HELD_W, 1e-6))
|
|
|
|
# Away from it, on part 1's fixture at a point mid-block, they must separate.
|
|
mid = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
for dt_s, w in FIXTURE[:-1]:
|
|
mid.add(sample(B + timedelta(seconds=dt_s), w))
|
|
NOW = B + timedelta(seconds=500)
|
|
p = PeakTracker.project(mid, NOW)
|
|
print(f" at t=500 s on part 1's fixture: acc/elapsed = {p.elapsed_w:.2f} W, "
|
|
f"projection = {p.projected_w:.2f} W, coverage = {p.covered_frac * 100:.1f} %")
|
|
check("mid-block, with a gap in it, the elapsed average and the projection are "
|
|
"DIFFERENT numbers", abs(p.elapsed_w - p.projected_w) > 1.0)
|
|
check("...and the projection carries elapsed_s, so a consumer can see it is an "
|
|
f"extrapolation over {QUARTER_S - p.elapsed_s:.0f} s it has not seen",
|
|
near(p.elapsed_s, 500.0, 1e-6))
|
|
check("...and a gap shows up as coverage below 1.0 rather than as a confident "
|
|
"low number", p.covered_frac < 1.0 and not p.complete)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("3. restart mid-block: the same block, or a lower one, never a lost one")
|
|
|
|
RUN_W = 2000.0
|
|
STEP_S = 1
|
|
RESTART_AT_S = 600 # minute 10 of the quarter - the functional note's case
|
|
|
|
|
|
def feed(avg, t0_s, t1_s, w=RUN_W):
|
|
out = []
|
|
for i in range(t0_s, t1_s + 1, STEP_S):
|
|
out += avg.add(sample(B + timedelta(seconds=i), w))
|
|
return out
|
|
|
|
|
|
# (a) the control: no restart at all.
|
|
ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
ctrl_closed = feed(ctrl, 0, QUARTER_S)
|
|
CTRL_W = ctrl_closed[0].offtake_avg_w
|
|
|
|
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
|
|
st = fresh_store("restart")
|
|
tr = PeakTracker(st, cap_w=LIVE_INSTALL_MAX_W)
|
|
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
feed(a1, 0, RESTART_AT_S)
|
|
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
|
|
a2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) # a brand-new process
|
|
restored = tr.restore(a2, B + timedelta(seconds=RESTART_AT_S + 1))
|
|
print(f" restore() said: {restored}")
|
|
check("...and restore() reports RESUMING rather than starting fresh",
|
|
restored.startswith("resumed"))
|
|
r_closed = feed(a2, RESTART_AT_S + STEP_S, QUARTER_S)
|
|
RESTART_W = r_closed[0].offtake_avg_w if r_closed else None
|
|
|
|
# (c) the mutation guard: the SAME restart with no restore.
|
|
a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S)
|
|
NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None
|
|
|
|
print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | "
|
|
f"restart with no restore {NORESTORE_W:.2f} W")
|
|
check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an "
|
|
f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6))
|
|
check("...and without the restore it lands LOWER - so 'the restore works' is "
|
|
"not satisfied by a fixture where restoring changes nothing",
|
|
NORESTORE_W is not None and NORESTORE_W < CTRL_W - 1.0)
|
|
# Derived, not asserted against a literal: the lost part is exactly the seconds
|
|
# before the restart, at the power that was flowing.
|
|
DROPPED_S = RESTART_AT_S + STEP_S # the resumed feed starts one step later
|
|
check(f"...and it is lower by exactly the {DROPPED_S} s it dropped "
|
|
f"({RUN_W * DROPPED_S / QUARTER_S:.2f} W)",
|
|
near(CTRL_W - NORESTORE_W, RUN_W * DROPPED_S / QUARTER_S, 1e-6))
|
|
|
|
# (d) a LONG outage: restored, but the meter was away past max_hold_s. The
|
|
# uncovered seconds must drag the block DOWN, not be held forward.
|
|
OUTAGE_S = 200
|
|
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
st4 = fresh_store("outage")
|
|
st4.save_partial(a1.snapshot())
|
|
PeakTracker(st4, cap_w=LIVE_INSTALL_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
|
|
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
|
|
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
|
|
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
|
|
f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: "
|
|
f"{o_closed[0].offtake_avg_w:.2f} W")
|
|
check(f"...reads lower than the uninterrupted block by exactly the uncovered "
|
|
f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is "
|
|
f"never held forward across the whole outage",
|
|
near(CTRL_W - o_closed[0].offtake_avg_w,
|
|
RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6))
|
|
|
|
# (e) double counting is answered by the primary key, not by care.
|
|
st5 = fresh_store("dedupe")
|
|
st5.record_block(ctrl_closed[0])
|
|
st5.record_block(ctrl_closed[0])
|
|
st5.record_block(ctrl_closed[0])
|
|
rows = st5.blocks_in(month_key(B))
|
|
check("banking the same quarter three times leaves ONE row - quarter_start is "
|
|
"the primary key, so a replay or a restart cannot double-count",
|
|
len(rows) == 1 and near(rows[0][1], CTRL_W, 1e-9))
|
|
|
|
# ...and a later pass with LESS coverage may not lower a banked figure.
|
|
from app.tariff import _ClosedBlock as _CB
|
|
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None))
|
|
after = st5.blocks_in(month_key(B))
|
|
check(f"...and re-banking the same quarter at half the figure leaves it at "
|
|
f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a "
|
|
f"quarter that was already measured in full",
|
|
len(after) == 1 and near(after[0][1], CTRL_W, 1e-9))
|
|
|
|
# (f) the checkpoint's own staleness bound, derived from the constant.
|
|
print(f" CHECKPOINT_S = {CHECKPOINT_S:.0f} s, so a crash can lose at most "
|
|
f"{CHECKPOINT_S / QUARTER_S * 100:.1f} % of one quarter's average")
|
|
check("the checkpoint bound is small enough that its worst case is under 2 % "
|
|
"of a quarter", CHECKPOINT_S / QUARTER_S < 0.02)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("4. month rollover, in LOCAL time, and across a restart that straddles it")
|
|
|
|
# CEST is UTC+2, so the last two hours of 31 August UTC are already September.
|
|
AUG_LATE = datetime(2026, 8, 31, 21, 45, tzinfo=timezone.utc)
|
|
SEP_EARLY = datetime(2026, 8, 31, 22, 15, tzinfo=timezone.utc)
|
|
print(f" {AUG_LATE.isoformat()} is local "
|
|
f"{AUG_LATE.astimezone(BILLING_TZ).isoformat()} -> {month_key(AUG_LATE)}")
|
|
print(f" {SEP_EARLY.isoformat()} is local "
|
|
f"{SEP_EARLY.astimezone(BILLING_TZ).isoformat()} -> {month_key(SEP_EARLY)}")
|
|
check("a quarter starting 21:45 UTC on 31 Aug bills in AUGUST",
|
|
month_key(AUG_LATE) == "2026-08")
|
|
check("a quarter starting 22:15 UTC on 31 Aug bills in SEPTEMBER - filing it "
|
|
"under August would move a whole month's bill",
|
|
month_key(SEP_EARLY) == "2026-09")
|
|
check("...and the two are genuinely on opposite sides of the boundary, not "
|
|
"the same answer twice", month_key(AUG_LATE) != month_key(SEP_EARLY))
|
|
# Winter, where the offset is +1 and the boundary sits an hour later.
|
|
JAN_LATE = datetime(2026, 1, 31, 22, 45, tzinfo=timezone.utc)
|
|
check("in WINTER the same trap is an hour later: 22:45 UTC on 31 Jan is still "
|
|
"January, and 23:15 UTC is February",
|
|
month_key(JAN_LATE) == "2026-01"
|
|
and month_key(JAN_LATE + timedelta(minutes=30)) == "2026-02")
|
|
|
|
st6 = fresh_store("months")
|
|
AUG_PEAK_W, SEP_PEAK_W = 4998.0, 900.0
|
|
st6.record_block(_CB(AUG_LATE, AUG_PEAK_W, None))
|
|
st6.record_block(_CB(SEP_EARLY, SEP_PEAK_W, None))
|
|
r_aug, r_sep = st6.peak(month="2026-08"), st6.peak(month="2026-09")
|
|
print(f" {r_aug.explain()}")
|
|
print(f" {r_sep.explain()}")
|
|
check(f"August's peak is the {AUG_PEAK_W:.0f} W quarter (specs.md:583's own "
|
|
f"measured case) and September's is not",
|
|
r_aug.is_observed and near(r_aug.peak_w, AUG_PEAK_W)
|
|
and r_sep.is_observed and near(r_sep.peak_w, SEP_PEAK_W))
|
|
check("...so a new month starts from its OWN quarters, not from the previous "
|
|
"month's peak", r_sep.peak_w < r_aug.peak_w)
|
|
|
|
# The restart that straddles the boundary: a checkpoint whose open block is in
|
|
# August, restored when `now` is already September.
|
|
st7 = fresh_store("straddle")
|
|
tr7 = PeakTracker(st7, cap_w=LIVE_INSTALL_MAX_W)
|
|
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
for i in range(0, 400, STEP_S):
|
|
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
|
|
st7.save_partial(straddler.snapshot())
|
|
after_boot = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
|
said = tr7.restore(after_boot, SEP_EARLY + timedelta(minutes=3))
|
|
print(f" restore() across the boundary said: {said}")
|
|
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
|
|
check("a restart that straddles the month boundary BANKS the dead August "
|
|
"quarter rather than dropping measured energy",
|
|
said.startswith("closed a stale block") and aug7.is_observed)
|
|
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
|
|
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
|
|
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
|
|
check("...and September reads NO_RECORD, which is a different answer from "
|
|
"August's peak AND from a zero",
|
|
sep7.state == NO_RECORD and sep7.peak_w is None)
|
|
check("...and the checkpoint is cleared, so a second boot cannot re-bank it",
|
|
st7.load_partial() is None)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
|
|
|
|
# Three stores, three states, and the three must not answer alike.
|
|
good = fresh_store("states")
|
|
good_tr = PeakTracker(good, cap_w=LIVE_INSTALL_MAX_W)
|
|
empty_r = good_tr.peak(B)
|
|
|
|
gone = fresh_store("gone")
|
|
gone_tr = PeakTracker(gone, cap_w=LIVE_INSTALL_MAX_W)
|
|
gone.record_block(_CB(B, 3000.0, None))
|
|
gone.close() # the database goes away under a live reader
|
|
gone_r = gone_tr.peak(B)
|
|
|
|
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
|
|
never_tr = PeakTracker(never, cap_w=LIVE_INSTALL_MAX_W)
|
|
never_r = never_tr.peak(B)
|
|
|
|
# A store that is OPEN and whose query FAILS is a third code path - a closed
|
|
# handle short-circuits before the SQL ever runs, so without this the
|
|
# `except sqlite3.Error` arm of peak() is never executed by anything.
|
|
broken = fresh_store("broken-table")
|
|
broken.record_block(_CB(B, 3000.0, None))
|
|
broken._db.execute("DROP TABLE monthly_peak")
|
|
broken._db.commit()
|
|
broken_r = PeakTracker(broken, cap_w=LIVE_INSTALL_MAX_W).peak(B)
|
|
|
|
for label, r in (("fresh store, no quarter yet", empty_r),
|
|
("store that broke under a live reader", gone_r),
|
|
("store that never opened", never_r),
|
|
("store open, query failed", broken_r)):
|
|
print(f" {label:38s} -> {r.state:13s} {r.explain()}")
|
|
|
|
check("a fresh store with no quarter yet says NO_RECORD - a normal state on "
|
|
"the 1st of the month", empty_r.state == NO_RECORD)
|
|
check("a store that broke says NOT_OBSERVED", gone_r.state == NOT_OBSERVED)
|
|
check("a store that never opened says NOT_OBSERVED", never_r.state == NOT_OBSERVED)
|
|
check("a store whose QUERY fails says NOT_OBSERVED too - a different code path "
|
|
"from a closed handle, and the one a half-corrupt database takes",
|
|
broken_r.state == NOT_OBSERVED and broken_r.peak_w is None)
|
|
# ...and losing the DERIVED record does not lose the peak, which is the whole
|
|
# argument for keeping monthly_peak recomputable from the 15-min tier.
|
|
_rec = broken.recompute_month(month_key(B))
|
|
print(f" ...but with the 15-min tier intact, recompute_month() still "
|
|
f"answers: {_rec.explain()}")
|
|
check("...while recompute_month() STILL answers from the surviving 15-min "
|
|
"tier - the permanent record is derived, so losing it is recoverable",
|
|
_rec.is_observed and near(_rec.peak_w, 3000.0))
|
|
broken._db.execute("DROP TABLE quarter_blocks")
|
|
broken._db.commit()
|
|
check("...and once BOTH tiers are gone it says NOT_OBSERVED rather than "
|
|
"raising into the control loop",
|
|
broken.recompute_month(month_key(B)).state == NOT_OBSERVED)
|
|
check("...and NO_RECORD and NOT_OBSERVED are DIFFERENT states, not two "
|
|
"spellings of the same one", empty_r.state != gone_r.state)
|
|
check("...and they read differently to a human, too",
|
|
empty_r.explain() != gone_r.explain()
|
|
and "NOT" in gone_r.explain() and "zero peak" in gone_r.explain())
|
|
|
|
check("an unreadable peak is None, not 0.0 - there is no number to mistake",
|
|
gone_r.peak_w is None and never_r.peak_w is None)
|
|
check("...and NO_RECORD carries no number either", empty_r.peak_w is None)
|
|
_typed = False
|
|
try:
|
|
float(gone_r.peak_w)
|
|
except TypeError:
|
|
_typed = True
|
|
check("...and a caller who forgets to ask gets a TypeError rather than a "
|
|
"silently disarming zero", _typed)
|
|
check("PeakReading exposes no accessor that defaults a missing peak to a "
|
|
"number - no .get(), no _or() ",
|
|
not [a for a in dir(gone_r)
|
|
if a.startswith(("get", "value")) or a.endswith("_or")])
|
|
|
|
# The consequence, which is the reason any of this matters.
|
|
print(f" headroom under a {LIVE_INSTALL_MAX_W:.0f} W limit: "
|
|
f"no quarter yet -> {good_tr.headroom_w(B)}, "
|
|
f"unreadable -> {gone_tr.headroom_w(B)}")
|
|
check("an unreadable store yields NO headroom figure - the disarming default "
|
|
"(full headroom, guard silently off) is unreachable",
|
|
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
|
|
check(f"...where a genuinely empty month yields the full {LIVE_INSTALL_MAX_W:.0f} W, "
|
|
f"which is correct and is a different answer",
|
|
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W))
|
|
check("the 80 % warning fraction is likewise None when nothing was observed",
|
|
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
|
|
|
|
# And a real peak reduces headroom, so "always None" does not pass this part.
|
|
good.record_block(_CB(B, LIVE_INSTALL_MAX_W * 0.9, None))
|
|
print(f" after one {LIVE_INSTALL_MAX_W * 0.9:.0f} W quarter: "
|
|
f"headroom {good_tr.headroom_w(B):.0f} W, "
|
|
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
|
|
check("a real observed peak gives a real headroom, so this part is not passed "
|
|
"by a store that answers None to everything",
|
|
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W * 0.1, 1e-6))
|
|
check(f"...and it is over §9.1's 80 % warning threshold",
|
|
good_tr.warning_frac(B) > 0.8)
|
|
|
|
# A CORRUPT database file is the third way a store fails, and it must land in
|
|
# NOT_OBSERVED too rather than raising into the control loop.
|
|
corrupt_path = TMP / "corrupt.db"
|
|
corrupt_path.write_bytes(b"this is not a database" * 64)
|
|
corrupt = PeakStore(str(corrupt_path))
|
|
c_r = PeakTracker(corrupt, cap_w=LIVE_INSTALL_MAX_W).peak(B)
|
|
print(f" a corrupt database file -> {c_r.state}")
|
|
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
|
|
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("6. the timezone fallback, graded against zoneinfo")
|
|
|
|
_zi = None
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
_zi = ZoneInfo("Europe/Brussels")
|
|
except Exception as err: # noqa: BLE001
|
|
print(f" zoneinfo unavailable here ({err}) - the fallback IS the tz")
|
|
|
|
if _zi is None:
|
|
check("zoneinfo is absent, so the encoded rule cannot be graded here - "
|
|
"reported, not silently passed", True)
|
|
else:
|
|
fb = _EUTimezone()
|
|
# ⚠️ HOURLY, NOT 3-HOURLY, ACROSS THE TRANSITION DAYS. Both EU transitions
|
|
# happen at 01:00 UTC, so a rule that is wrong BY AN HOUR differs from the
|
|
# right one only between 01:00 and 02:00 UTC. A 3-hourly sweep steps over
|
|
# that window entirely and grades nothing - measured: a mutation moving the
|
|
# UTC threshold from 01:00 to 02:00 survived the whole file at 3-hourly.
|
|
probes = [datetime(2026, 1, 1, 12, tzinfo=timezone.utc)]
|
|
probes += [datetime(2026, m, d, h, mi, tzinfo=timezone.utc)
|
|
for m in (3, 10) for d in (28, 29, 30, 31)
|
|
for h in range(24) for mi in (0, 30)]
|
|
probes += [datetime(2026, m, 1, 0, 30, tzinfo=timezone.utc) for m in range(1, 13)]
|
|
# ⚠️ COMPARE THE CONVERTED WALL CLOCK, NOT ITS utcoffset(). `astimezone`
|
|
# goes through `fromutc`, but the RESULT carries local wall values, so
|
|
# asking it for `.utcoffset()` routes straight back into the LOCAL arm and
|
|
# never grades `fromutc` at all. Measured: a mutation moving the UTC
|
|
# threshold by an hour survived 397 offset-comparing probes and dies
|
|
# immediately on the wall clock they produce.
|
|
def _wallclock(t, tz):
|
|
return t.astimezone(tz).replace(tzinfo=None)
|
|
|
|
bad = [t for t in probes if _wallclock(t, fb) != _wallclock(t, _zi)]
|
|
print(f" {len(probes)} probes across both DST transitions and every "
|
|
f"month boundary: {len(bad)} disagreement(s) in the LOCAL WALL CLOCK "
|
|
f"each one converts to")
|
|
check("the encoded EU rule converts every probe to the same local wall "
|
|
"clock as zoneinfo, including the two transition weekends", not bad)
|
|
# ...and the probes are not all the same offset, or the check is vacuous.
|
|
offsets = {t.astimezone(_zi).utcoffset() for t in probes}
|
|
check(f"...and the probe set spans BOTH offsets ({sorted(str(o) for o in offsets)}), "
|
|
f"so agreeing is not free", len(offsets) == 2)
|
|
check(f"BILLING_TZ resolved to zoneinfo on this host, not the fallback",
|
|
not isinstance(BILLING_TZ, _EUTimezone))
|
|
|
|
# ⚠️ THE PROBES ABOVE ONLY EXERCISE `fromutc()`. `astimezone` on a UTC
|
|
# instant never calls `utcoffset()` with a local wall time, so the local
|
|
# arm of the rule was ungraded - a mutation that moved it by an hour
|
|
# survived the whole file. These probes drive it directly. Noon is used
|
|
# because it is unambiguous on every day of the year, including the two
|
|
# transition days where 02:00-03:00 local is missing or repeated.
|
|
#
|
|
# ⚠️ AND THE LOCAL PROBES MUST INCLUDE 03:00-03:59 ON THE OCTOBER SUNDAY.
|
|
# That hour is where a local rule that ends DST an hour late still claims
|
|
# CEST while zoneinfo has already returned to CET; at noon both answers
|
|
# agree and the probe is worth nothing. Measured: at noon-only, a mutation
|
|
# moving the local thresholds from 02:00/03:00 to 03:00/04:00 survived.
|
|
#
|
|
# The MARCH gap (02:00-02:59 local, which does not exist) is deliberately
|
|
# NOT probed: Python hands a nonexistent local time to `utcoffset` as an
|
|
# ordinary value and any answer is defensible, so grading it would pin an
|
|
# arbitrary choice. It cannot move a month boundary - it is the last Sunday
|
|
# of March, never the 1st of a month - which is the only thing this tz is
|
|
# used for.
|
|
wall = [datetime(2026, m, d, 12) for m, d in
|
|
[(3, 27), (3, 28), (3, 29), (3, 30), (10, 24), (10, 25),
|
|
(10, 26), (10, 27)]]
|
|
wall += [datetime(2026, 10, 25, h, mi) for h in range(0, 6) for mi in (0, 30)
|
|
if h != 2] # 02:00 local is the ambiguous fold
|
|
wall += [datetime(2026, 3, 29, h, mi) for h in (0, 1, 3, 4, 5) for mi in (0, 30)]
|
|
wall += [datetime(2026, m, 1, 12) for m in range(1, 13)]
|
|
bad_w = [w for w in wall
|
|
if w.replace(tzinfo=fb).utcoffset() != w.replace(tzinfo=_zi).utcoffset()]
|
|
w_offsets = {w.replace(tzinfo=_zi).utcoffset() for w in wall}
|
|
print(f" {len(wall)} LOCAL-wall probes through utcoffset(): "
|
|
f"{len(bad_w)} disagreement(s), spanning "
|
|
f"{sorted(str(o) for o in w_offsets)}")
|
|
check("utcoffset() on local wall time matches zoneinfo across both "
|
|
"transition weekends - the arm astimezone() never reaches", not bad_w)
|
|
check("...and those probes span both offsets too", len(w_offsets) == 2)
|
|
bad_n = [w for w in wall
|
|
if w.replace(tzinfo=fb).tzname() != w.replace(tzinfo=_zi).tzname()]
|
|
check("...and tzname() agrees with zoneinfo at every one of them (CET/CEST)",
|
|
not bad_n)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("7. the per-phase reduction, on an UNBALANCED three-phase load (§17)")
|
|
|
|
# One phase exporting is what separates the three policies. On a balanced load
|
|
# they all give the same number, which is why this fixture is not balanced.
|
|
PH = (3000.0, 500.0, -200.0)
|
|
NET = sum(PH)
|
|
u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
|
u_closed = []
|
|
for i in range(0, QUARTER_S + 1, 5):
|
|
u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH))
|
|
blk = u_closed[0]
|
|
vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
|
print(f" phases {PH} (net {NET:+.0f} W) -> "
|
|
+ ", ".join(f"{k}={v:.0f} W" for k, v in vals.items()))
|
|
check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)",
|
|
near(vals[POLICY_NET], NET, 1e-6))
|
|
check(f"the SUM policy clips each phase first, so the exporting phase does not "
|
|
f"net off ({sum(max(v, 0.0) for v in PH):.0f} W)",
|
|
near(vals[POLICY_SUM], sum(max(v, 0.0) for v in PH), 1e-6))
|
|
check(f"the MAX policy bills the worst single phase ({max(PH):.0f} W)",
|
|
near(vals[POLICY_MAX], max(PH), 1e-6))
|
|
check("...and on THIS fixture all three are different numbers - which is the "
|
|
"whole reason the reduction is a named policy and not an assumption",
|
|
len({round(v, 6) for v in vals.values()}) == 3)
|
|
check("the shipped default is NET, the meter's own billed register",
|
|
billed_w(blk) == vals[POLICY_NET])
|
|
|
|
bal = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
|
b_closed = []
|
|
for i in range(0, QUARTER_S + 1, 5):
|
|
b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3,
|
|
phase_net=(1000.0, 1000.0, 1000.0)))
|
|
b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
|
check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), "
|
|
f"so a balanced fixture could not have told them apart",
|
|
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
|
|
|
|
# --- TEST-13 item 1: the POLICY ARGUMENT'S PLUMBING, not just billed_w() ------
|
|
# `PeakStore.record_block(block, policy)` really does call `billed_w(block,
|
|
# policy)` - the product code is correct and is not what this adds. What was
|
|
# missing is that NO CHECK COULD TELL that call apart from a hardcoded
|
|
# `billed_w(block, POLICY_NET)`. Mutating it to exactly that left all 17 suites
|
|
# green, twice measured (TARIFF-01's reviewer, re-run through the purge harness
|
|
# for TEST-15 and again here).
|
|
#
|
|
# The reason is this section's own subject, one level up. `billed_w()`
|
|
# short-circuits to `block.offtake_avg_w` whenever `per_phase_offtake_avg_w` is
|
|
# absent, and EVERY block that reached a store anywhere in this file was
|
|
# single-phase or built with `per_phase_offtake_avg_w=None` - so all three
|
|
# policies collapsed onto one number and the argument was unobservable at the
|
|
# call site. The checks above grade the FUNCTION on an unbalanced fixture; they
|
|
# say nothing about whether its CALLER forwards what it was handed. That is the
|
|
# same shape as WITNESS-02's span normalisation and TARIFF-01's own DST window:
|
|
# a stimulus evaluated where the quantity it grades is degenerate.
|
|
#
|
|
# So the stimulus here is `blk` - the unbalanced three-phase block from the top
|
|
# of this section, whose three reductions were asserted three lines up to be
|
|
# three DIFFERENT numbers. A `record_block()` that ignores its argument stores
|
|
# one policy's figure under all three names and at least two of these
|
|
# round-trips go red.
|
|
#
|
|
# Which direction the defect runs in, because that is what makes it worth
|
|
# plumbing-testing at all: NET is the SMALLEST of the three on this fixture (the
|
|
# exporting phase nets off against the other two). A store that silently billed
|
|
# NET while its caller asked for MAX would under-record the month's peak, and an
|
|
# under-recorded peak is maximum headroom for SAFETY-07 - the same
|
|
# under-guarding direction as item 4's `getattr(..., 0.0)` default.
|
|
_banked = {}
|
|
for _pol in (POLICY_NET, POLICY_SUM, POLICY_MAX):
|
|
_st = fresh_store(f"policy-{_pol}")
|
|
_st.record_block(blk, _pol)
|
|
_read = _st.peak(month=month_key(blk.start))
|
|
_derived = _st.recompute_month(month_key(blk.start))
|
|
_banked[_pol] = _read.peak_w
|
|
check(f"record_block(block, {_pol!r}) banks {vals[_pol]:.0f} W - the store "
|
|
f"applies the reduction it was HANDED, not one of its own",
|
|
_read.is_observed and near(_read.peak_w, vals[_pol]))
|
|
check(f"...and re-deriving from the 15-min tier gives the same {_pol!r} "
|
|
f"figure, so BOTH writes in that one transaction used that reduction",
|
|
_derived.is_observed and near(_derived.peak_w, vals[_pol]))
|
|
|
|
_st_def = fresh_store("policy-default")
|
|
_st_def.record_block(blk)
|
|
check(f"...and record_block() with NO policy argument banks the NET figure "
|
|
f"({vals[POLICY_NET]:.0f} W), matching billed_w()'s own shipped default",
|
|
near(_st_def.peak(month=month_key(blk.start)).peak_w, vals[POLICY_NET]))
|
|
# The anti-degeneracy pin, stated over the BANKED numbers rather than over
|
|
# `vals`, because it is the banked ones this triple is made of. If a future
|
|
# fixture change ever made two of these coincide, the triple above would start
|
|
# passing under a hardcoded policy and would be silently back where it started.
|
|
print(f" banked through the store: "
|
|
+ ", ".join(f"{k}={v:.0f} W" for k, v in _banked.items()))
|
|
check("the three BANKED figures are three distinct numbers, so this triple is "
|
|
"not itself degenerate - it can only pass if the argument is read",
|
|
len({round(v, 6) for v in _banked.values() if v is not None}) == 3)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("8. monthly_peak is DERIVED, and provably has not diverged")
|
|
|
|
st8 = fresh_store("derive")
|
|
QUARTERS = [(0, 1200.0), (1, 4998.0), (2, 3357.0), (3, 800.0), (4, 4832.0)]
|
|
for q, w in QUARTERS:
|
|
st8.record_block(_CB(B + timedelta(seconds=q * QUARTER_S), w, None))
|
|
MAXQ = max(w for _, w in QUARTERS)
|
|
stored = st8.peak(month=month_key(B))
|
|
derived = st8.recompute_month(month_key(B))
|
|
print(f" {len(QUARTERS)} quarters banked; stored peak {stored.peak_w:.0f} W, "
|
|
f"recomputed from quarter_blocks {derived.peak_w:.0f} W")
|
|
check(f"the stored monthly peak is the largest quarter ({MAXQ:.0f} W - "
|
|
f"specs.md:583's own figure)",
|
|
stored.is_observed and near(stored.peak_w, MAXQ))
|
|
check("...and re-deriving it from the 15-min tier gives the same number and "
|
|
"the same quarter, so the two records are proven equal rather than "
|
|
"assumed to be",
|
|
derived.is_observed and near(derived.peak_w, stored.peak_w)
|
|
and derived.quarter_start == stored.quarter_start)
|
|
check("...and the two measured regressions from §9.1 land on DIFFERENT peaks - "
|
|
f"the sustained {MAXQ:.0f} W quarter, not the oscillating 3357 W one",
|
|
not near(stored.peak_w, 3357.0))
|
|
# A quarter arriving LATER and lower must not lower the month.
|
|
st8.record_block(_CB(B + timedelta(seconds=9 * QUARTER_S), 100.0, None))
|
|
check("a later, smaller quarter does not lower the month's peak",
|
|
near(st8.peak(month=month_key(B)).peak_w, MAXQ))
|
|
check("...and the recompute agrees with it after that write, too",
|
|
near(st8.recompute_month(month_key(B)).peak_w, MAXQ))
|
|
# No pruning: §15 says these two tiers are indefinite / permanent.
|
|
src = (HERE / "app" / "tariff.py").read_text(encoding="utf-8")
|
|
check("§15's retention is structural: no DELETE against quarter_blocks or "
|
|
"monthly_peak exists anywhere in tariff.py",
|
|
"DELETE FROM quarter_blocks" not in src
|
|
and "DELETE FROM monthly_peak" not in src
|
|
and "DROP TABLE" not in src)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("9. AC 3, on the replay data that actually exists")
|
|
|
|
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
|
print(f" replay fixtures found: {len(REPLAY)} "
|
|
f"({', '.join(p.name for p in REPLAY) or 'none'})")
|
|
check("the committed replay fixtures are present - a regression fixture CI "
|
|
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
|
|
|
# ⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with the
|
|
# default, once under `schema:` with `int(500,15000)`. This used to take the
|
|
# FIRST match on a STRIPPED line and call that the options block; CFG-04 made
|
|
# stripped-line-first-match unsafe in this file's neighbours (a `max_w:` nested
|
|
# six spaces under `inverters:` in the schema block began outranking the
|
|
# top-level one), so it is anchored the same way they now are: the options half
|
|
# of the file, at exactly two spaces of indentation. `peak_cap_w` is not a
|
|
# per-inverter key today and this reader was never wrong - it is the IDIOM that
|
|
# was, and leaving one copy of it here would be leaving the next per-inverter
|
|
# key a place to land. test_inverter.py part 12 runs this function against a
|
|
# two-inverter config.
|
|
def shipped_option(key):
|
|
"""The `options:` default for `key`, anchored at two spaces. None if absent."""
|
|
body = (HERE / "config.yaml").read_text(encoding="utf-8")
|
|
for line in body[:body.index("\nschema:")].splitlines():
|
|
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
|
try:
|
|
return float(line.split(":", 1)[1].strip())
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
SHIPPED_CAP_W = shipped_option("peak_cap_w")
|
|
check("config.yaml's shipped peak_cap_w was read out of the file rather than "
|
|
"typed here", SHIPPED_CAP_W is not None)
|
|
|
|
replay_days = 0
|
|
total_q = 0
|
|
under = 0
|
|
for path in REPLAY:
|
|
st9 = fresh_store("replay-" + path.stem)
|
|
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
|
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_INSTALL_MAX_W)
|
|
first = last = None
|
|
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
row = json.loads(line)
|
|
t = datetime.fromisoformat(row["t"])
|
|
first = first or t
|
|
last = t
|
|
ing.submit(sample(t, float(row["w"])))
|
|
tr9.sync(ing)
|
|
months = {month_key(b.start) for b in ing.blocks}
|
|
span_h = (last - first).total_seconds() / 3600.0
|
|
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
|
|
peak = max(qs) if qs else 0.0
|
|
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_INSTALL_MAX_W))
|
|
total_q += len(qs)
|
|
under += ok_q
|
|
replay_days += span_h / 24.0
|
|
print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, "
|
|
f"month peak {peak:.0f} W, "
|
|
f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the "
|
|
f"{SHIPPED_CAP_W:.0f} W soft limit")
|
|
check(f"{path.name}: the month's peak equals its own largest quarter",
|
|
near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
|
|
check(f"{path.name}: a full day of real telegrams produces ~96 quarters, "
|
|
f"got {len(qs)}", 90 <= len(qs) <= 96)
|
|
|
|
PCT = 100.0 * under / max(total_q, 1)
|
|
print()
|
|
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
|
|
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
|
|
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
|
|
print(f" AC 3 ASKS FOR 30 DAYS and this run is {replay_days:.1f}, so it "
|
|
f"does not settle it either way. The 30 days DO now exist - "
|
|
f"scenarios/p1-net-30d-2026-08/, DATA-01 - and "
|
|
f"tools/replay_ac3.py is what runs them; the figure above is the "
|
|
f"BASELINE SAFETY-07 has to improve, on the two days CI replays.")
|
|
check("the replay produced a measurable number over a stated sample size, "
|
|
"rather than a criterion quietly redefined to fit the data",
|
|
total_q > 100 and replay_days < 30)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("10. the wiring: the real Controller, and what its status page says")
|
|
|
|
|
|
class _Store:
|
|
data = {}
|
|
|
|
def set(self, *a):
|
|
pass
|
|
|
|
def get_time(self, *a):
|
|
return None
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
async def limits(self, entity):
|
|
return (-LIVE_INSTALL_MAX_W, LIVE_INSTALL_MAX_W, 1.0)
|
|
|
|
async def number(self, entity, invert=False):
|
|
return None
|
|
|
|
async def set_number(self, entity, value):
|
|
return True
|
|
|
|
async def call(self, domain, service, data):
|
|
return True
|
|
|
|
|
|
def built(db_path, opts=None):
|
|
return Controller(dict(
|
|
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
|
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
|
"max_w": LIVE_INSTALL_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
|
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
|
"stale_input_s": 15}, **(opts or {})),
|
|
_Hass(), _Store(), _Pub(), stop=asyncio.Event(),
|
|
peak_db_path=str(db_path))
|
|
|
|
|
|
c = built(TMP / "wired.db")
|
|
check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED "
|
|
f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot "
|
|
f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W)
|
|
|
|
# Drive one real quarter through the real ingest, then the real cycle's sync.
|
|
for i in range(0, QUARTER_S + 1, 5):
|
|
c.p1.submit(sample(B + timedelta(seconds=i), 4998.0))
|
|
n = c.peak.sync(c.p1)
|
|
wr = c.peak.peak(B)
|
|
print(f" after one 4998 W quarter through the real ingest: "
|
|
f"{n} banked, {wr.explain()}")
|
|
check("the closed quarter reached SQLite through the tracker the cycle calls",
|
|
n == 1 and wr.is_observed and near(wr.peak_w, 4998.0, 1e-6))
|
|
|
|
# ⚠️ `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 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; 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_in(B) == 1)
|
|
check("...and the peak is unchanged by the repeat",
|
|
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
|
|
|
# ⚠️ `when=B`, AND THAT ARGUMENT IS THE TICKET (TEST-17). Without it `checks()`
|
|
# reads the peak for `datetime.now()`'s month while this fixture banked its
|
|
# quarter in August, so every check below passed in August and failed from
|
|
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month
|
|
# it created, not the month the machine happens to be running in.
|
|
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]]
|
|
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
|
|
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 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 "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"])
|
|
|
|
# The half that matters: the same page with a store that cannot answer.
|
|
c2 = built(TMP / "no-such-dir" / "nope.db")
|
|
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]]
|
|
print(" status row (unreadable store): "
|
|
+ (u_rows[0]["text"] if u_rows else "(none)"))
|
|
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
|
|
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
|
|
"instrument looks exactly like a month with no peak",
|
|
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
|
|
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
|
|
check("...and the healthy row and the unreadable row are different text",
|
|
bool(u_rows) and bool(peak_rows)
|
|
and u_rows[0]["text"] != peak_rows[0]["text"])
|
|
|
|
# --- TEST-17: the page must be graded at a stated time, and prove it ----------
|
|
# Three ways this can regress, so three checks. The first two would both go
|
|
# green again if `checks()` quietly went back to reading the clock, which is
|
|
# exactly the mutation this section could not previously detect.
|
|
|
|
# ⚠️ SELECTED ON "quarter(s) banked", NOT ON "peak". The NO_RECORD row reads
|
|
# "2026-10: no completed quarter recorded yet - ..." and contains neither the
|
|
# word "peak" nor "capacity-tariff" - only the UNREADABLE branch prefixes the
|
|
# family name. Filtering these rows the way the checks above do would drop the
|
|
# row and then grade its absence, which is the very mistake this ticket fixes.
|
|
# (That asymmetry in the row's own text is worth a look on its own; it is what
|
|
# made the original filter brittle. Not changed here - it is product text with
|
|
# other suites reading it.)
|
|
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]]
|
|
|
|
_later = B + timedelta(days=45) # same fixture, a later billing month
|
|
_other_peak = _peak_rows(c.checks(when=_later))
|
|
print(f" the SAME controller read at {_later.date()}: "
|
|
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
|
|
check("asked for a LATER month, the same controller reports that month's "
|
|
"absence of a quarter, not August's 4998 W - the row follows `when`",
|
|
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"]
|
|
and "no completed quarter recorded yet" in _other_peak[0]["text"])
|
|
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
|
|
"an unreadable store - a month with no quarter is not a broken instrument",
|
|
bool(_other_peak) and bool(peak_rows)
|
|
and "UNREADABLE" not in _other_peak[0]["text"]
|
|
and _other_peak[0]["text"] != peak_rows[0]["text"])
|
|
check("...and it is not FLAGGED, because an empty month is not an exceedance",
|
|
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
|
|
|
|
# The mutation guard. `checks()` ignoring `when` is the whole defect, and it is
|
|
# invisible to every check above whenever the machine's clock happens to sit in
|
|
# the fixture's own month - which is how this shipped. Grading two DIFFERENT
|
|
# reference months against each other cannot be satisfied by a clock at all.
|
|
check("the peak row differs between two reference months, so a `checks()` that "
|
|
"dropped `when` and read datetime.now() would fail HERE regardless of "
|
|
"what day the suite is run on",
|
|
bool(peak_rows) and bool(_other_peak)
|
|
and peak_rows[0]["text"] != _other_peak[0]["text"])
|
|
|
|
# And the production meaning is unchanged: no argument still means today.
|
|
_now_peak = _peak_rows(c.checks())
|
|
_today_is_fixture_month = month_key(datetime.now(timezone.utc)) == month_key(B)
|
|
check("calling checks() with NO argument still reads the month being billed "
|
|
"today - the live status page's meaning is untouched",
|
|
bool(_now_peak) and (bool(peak_rows)
|
|
and _now_peak[0]["text"] == peak_rows[0]["text"]
|
|
if _today_is_fixture_month
|
|
else "no completed quarter recorded yet"
|
|
in _now_peak[0]["text"]))
|
|
|
|
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
|
|
# SYNTAX, not on the text: the module docstring argues this at length and names
|
|
# every forbidden word while doing so, so a grep over the source would be
|
|
# satisfied by deleting the explanation and would fail on writing one. What must
|
|
# be true is that no NAME the code evaluates refers to a command.
|
|
import ast as _ast
|
|
|
|
_tree = _ast.parse(src)
|
|
_names = {n.id for n in _ast.walk(_tree) if isinstance(n, _ast.Name)}
|
|
_names |= {n.attr for n in _ast.walk(_tree) if isinstance(n, _ast.Attribute)}
|
|
_names |= {n.name for n in _ast.walk(_tree)
|
|
if isinstance(n, (_ast.FunctionDef, _ast.ClassDef))}
|
|
_FORBIDDEN = {"setpoint", "write_setpoint", "target", "Decision", "i_clamped",
|
|
"clamped", "Claim", "resolve", "P_PEAK", "arbiter"}
|
|
_found = sorted(_names & _FORBIDDEN)
|
|
|
|
# ⚠️ THE IMPORT HALF WALKS `Import` TOO, AND READS THE IMPORTED NAMES - TEST-13
|
|
# item 2. It used to be `m.module for m in walk(_tree) if isinstance(m,
|
|
# ImportFrom)`, matched against a literal list of six spellings, and that is
|
|
# blind to TWO OF PYTHON'S THREE import forms:
|
|
#
|
|
# * `from . import arbiter as _ab` is an `ImportFrom` whose `.module` is
|
|
# **None** - the dots live in `.level` and the module name lives in
|
|
# `.names` - so the old grader collected `""` and matched nothing;
|
|
# * `import app.arbiter` is an `ast.Import` node, a different class the walk
|
|
# filtered out entirely and therefore never looked at.
|
|
#
|
|
# Neither was a live exposure: the `_names` half above catches any USE of either
|
|
# alias, because reaching anything through it produces an `Attribute` whose
|
|
# `.attr` is a forbidden name. The defect was in the SENTENCE. A check that
|
|
# prints "it imports nothing from arbiter, clamp or control" while verifying
|
|
# that for one spelling out of three is worse than one that claims less, because
|
|
# the next reader stops looking. So: collect every module any import statement
|
|
# names, in all three forms, and match per dotted SEGMENT - `app.arbiter` and
|
|
# `.arbiter` must not be able to hide behind a package prefix either.
|
|
_FORBIDDEN_MODULES = {"arbiter", "clamp", "control"}
|
|
|
|
|
|
def _imported_modules(tree):
|
|
"""Every module path an import statement in `tree` names, all three forms."""
|
|
out = set()
|
|
for node in _ast.walk(tree):
|
|
if isinstance(node, _ast.Import):
|
|
# `import app.arbiter`, `import app.arbiter as x` - the whole
|
|
# dotted path is the module.
|
|
out |= {a.name for a in node.names}
|
|
elif isinstance(node, _ast.ImportFrom):
|
|
base = node.module or "" # None for `from . import arbiter`
|
|
if base:
|
|
out.add(base) # `from .arbiter import resolve`
|
|
# ...and in the `module is None` form the MODULE is in .names, so
|
|
# the names have to be joined on whether or not there is a base.
|
|
out |= {f"{base}.{a.name}" if base else a.name for a in node.names}
|
|
return out
|
|
|
|
|
|
def _forbidden_imports(mods):
|
|
# Segment-wise, not substring: `arbitrage` contains no forbidden segment and
|
|
# must not be flagged, while `app.arbiter` must be.
|
|
return sorted(m for m in mods
|
|
if _FORBIDDEN_MODULES & {p for p in m.split(".") if p})
|
|
|
|
|
|
_imports = sorted(_imported_modules(_tree))
|
|
print(f" tariff.py evaluates {len(_names)} distinct names and imports "
|
|
f"{sorted({m.split('.')[0] for m in _imports}) or ['(nothing)']}")
|
|
check(f"none of them is a command-side name {sorted(_FORBIDDEN)} - the utility "
|
|
f"bills what FLOWED, so this reads the meter and nothing else"
|
|
+ (f"; found {_found}" if _found else ""), not _found)
|
|
check("...and it imports nothing from arbiter, clamp or control in ANY of the "
|
|
"three import spellings: it files no claim, applies no clamp, and "
|
|
"commands nothing - SAFETY-07's job",
|
|
not _forbidden_imports(_imports))
|
|
|
|
# The import grader graded. Every one of these is a real way to reach the
|
|
# command side from this module, and the first two are the ones the previous
|
|
# version let through; the last two it already caught and must keep catching.
|
|
_EVASIONS = ["from .arbiter import resolve",
|
|
"from . import arbiter as _ab",
|
|
"import app.arbiter",
|
|
"from app.control import compute",
|
|
"import app.clamp as _c"]
|
|
_missed = [e for e in _EVASIONS
|
|
if not _forbidden_imports(_imported_modules(_ast.parse(e)))]
|
|
check(f"...and that import grader is not spelling-blind: all {len(_EVASIONS)} "
|
|
f"ways of importing the command side are caught"
|
|
+ (f"; MISSED {_missed}" if _missed else ""), not _missed)
|
|
# ...and it is not simply flagging everything, which would make the line above
|
|
# free. The last three are the reason the match is per SEGMENT and not `f in m`:
|
|
# `clamps`, `arbiters` and `controller` all CONTAIN a forbidden word and none of
|
|
# them is the command side. `controller` is not hypothetical - this package is
|
|
# `goodwe_controller`, and a substring grader pointed at a path rather than a
|
|
# module name would refuse the whole repository.
|
|
_INNOCENT = ["import json", "from datetime import datetime, timezone",
|
|
"from . import arbitrage", "from .price import Uncertainty",
|
|
"from zoneinfo import ZoneInfo",
|
|
"import clamps", "from .arbiters import x",
|
|
"from . import controller"]
|
|
_false = [i for i in _INNOCENT
|
|
if _forbidden_imports(_imported_modules(_ast.parse(i)))]
|
|
check("...and it does not fire on innocent imports whose names merely CONTAIN a "
|
|
"forbidden word (`clamps`, `arbiters`, `controller`) - a substring match "
|
|
"would have"
|
|
+ (f"; FALSELY FLAGGED {_false}" if _false else ""), not _false)
|
|
|
|
# ...and neither half is vacuous: the same two graders run over main.py, which
|
|
# DOES command, must both light up. main.py carries all three import forms.
|
|
_main_tree = _ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
|
_main = {n.attr for n in _ast.walk(_main_tree) if isinstance(n, _ast.Attribute)}
|
|
_main_imports = _forbidden_imports(_imported_modules(_main_tree))
|
|
check(f"the same NAME grader over main.py finds {sorted(_main & _FORBIDDEN)}, "
|
|
f"so it is capable of failing", bool(_main & _FORBIDDEN))
|
|
check(f"...and the same IMPORT grader over main.py finds {_main_imports}, so "
|
|
f"it is capable of failing on a real module and not only on a fixture",
|
|
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)
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|