The instrument the money guard will read. It measures the number the Belgian
capaciteitstarief bills on - the highest 15-minute average offtake of the calendar
month (Fluvius / Resa / Ores) - and writes it somewhere a restart cannot reach.
It files no claim, applies no clamp and commands nothing; that is SAFETY-07.
Per the PM comment of 2026-08-30 this is built in goodwe_controller/app/, not
in the ems/ layout the card's functional notes name. Read ems/tariff/... as app/tariff.py.
AC 1 was already built and unwired - the honest scope
p1.QuarterAverager already computes AC 1 in full: clock-aligned 900 s blocks,
time-weighted over irregular telegram spacing, offtake-only, per-phase, with a max_hold_s gap rule. P1Ingest.blocks was a 96-entry list in memory that a
restart erased. So this PR is the persistence and the monthly reduction, not a
second accumulator - a second one would be a second source of truth for the exact
quantity the money depends on. The 2026-08-27 backlog-audit comment on the card
called this correctly.
Block vs rolling, and what settled it
The billed unit is a clock-aligned 15-minute BLOCK. The Description says
"rolling"; AC 1 says "15-min block average". §9.1 settles it twice over: it calls
the billed unit "the highest 15-minute average offtake", and its own measured case
"one 4998 W quarter moved a monthly peak from 4832 -> 5013 W" - is stated per
quarter, an object a sliding window does not have. "Rolling" in the Description
means CONTINUOUSLY UPDATED (the open block's partial average is readable at every
sample), not a trailing-15-minutes window.
This is not a tie broken by preference. A trailing window reports a peak the meter
never bills, and it reports it higher than the real one: a load burst
straddling :15 fills a trailing window completely while contributing to two
block averages at partial weight. Guarding that spends battery defending an
interval nobody charges for.
Delivered power, not intended power (SAFETY-20)
SAFETY-20 has an open question about whether guards grade Decision.target
(pre-clamp intent) or standing (the wire value). For a capacity tariff there is
no question: the utility's meter integrates the current that flowed. tariff.py
never sees a setpoint at all - it consumes P1Sample, the meter's own reading.
A peak computed from intent would be wrong in the expensive direction: a clamped
9 kW request that delivered 5 kW would record 9 kW and every consumer would shave
against a peak that never existed.
This is asserted, not asserted-in-prose. test_tariff.py part 10 parses tariff.py and checks that no name it evaluates is a command-side name
(setpoint, target, Decision, i_clamped, clamped, Claim, P_PEAK, resolve, arbiter), and that it imports nothing from arbiter/clamp/ control. It is graded on the parsed syntax rather than by grep, because the
module docstring argues the point at length and names every forbidden word doing
so - a grep would be satisfied by deleting the explanation. The same grader is
then run over main.py, which DOES command, to prove it can fail.
Persistence
SQLite at /data/tariff.db, two §15 tiers, and no DELETE against either
anywhere in the module (checked structurally in part 8):
table
tier
retention
quarter_blocks
15-min aggregates
indefinite
monthly_peak
monthly peak records
permanent
monthly_peak is a derived record - upserted on each block close, and recompute_month() re-derives it from quarter_blocks so the two are proven
equal rather than assumed to be. Part 5 also shows that dropping monthly_peak
loses nothing while the 15-min tier survives, which is the argument for keeping it
derived.
Restart mid-block.QuarterAverager grew a snapshot()/restore() pair; the
tracker checkpoints the open block every CHECKPOINT_S = 15 s (worst case
15/900 = 1.7 % of one quarter, computed from the constants in the check, not
quoted) and clears the checkpoint in the same breath as a block close. Three
cases, all exercised:
checkpoint's block contains now -> resume verbatim, including the held
sample's original timestamp, so the outage is integrated under the same max_hold_s rule a running process would apply. Uncovered seconds therefore
drag the closed block DOWN instead of being held forward across the outage;
checkpoint's block ended while we were down -> that block really happened, so
it is banked at acc / 900 (partial coverage, reads low) rather than discarded;
no checkpoint, or one that does not fit (a meter_phases change) -> start clean
and say which. restore() returns the sentence, and the status page prints
it.
Double counting is answered structurally, not by care: quarter_start is the
PRIMARY KEY and a re-bank may only ever RAISE a figure
(ON CONFLICT ... WHERE excluded.offtake_avg_w > ...). A replay is a no-op; a
restart that reprocesses a quarter with less coverage cannot lower one already
banked.
Month rollover. Calendar months are LOCAL (Europe/Brussels); blocks are stored
UTC. A quarter starting 2026-08-31 22:15 UTC bills in September - filing it
under August moves a month's bill. Block alignment still needs no timezone
(every Belgian offset is a whole number of hours). zoneinfo is used where
available with an encoded EU-rule fallback, graded against zoneinfo at 397 UTC
probes and 40 local-wall probes.
An unreadable store is not a zero peak. This is sim/riglock.py's holder()
fix applied to something that spends money, and here the degenerate value is not
merely wrong, it is disarming: a peak read as 0 W hands SAFETY-07 the maximum
possible headroom and silently switches the guard off at the moment its own
storage is broken. So peak() answers OBSERVED / NO_RECORD / NOT_OBSERVED, peak_w is None unless OBSERVED, and there is no accessor that defaults it - a
caller who forgets gets a TypeError. headroom_w() returns None, never cap_w. Four failure paths land in NOT_OBSERVED and are each exercised: never
opened, no durable volume, a closed handle, and an open handle whose query fails.
The HA-side overlap, named and decided
peak_forecast_entity + peak_cap_w already exist, read in Controller.read_inputs() and consumed by control.maintenance_charge_floor() / peak_at_risk(). That is the existing HA-side delegation the PM comment warns
about.
Decision: kept, unchanged, and not duplicated. Repointing maintenance_charge_floor() at this measurement changes what moves energy during
a maintenance charge, which needs its own acceptance and belongs to SAFETY-07 - a
measurement ticket should not be the thing that alters a charge current. What this
PR adds is the measured month peak, which is a different quantity from a
forecast entity, and it is published as one status row, not as a second entity
shadowing the first. The swap is a one-line change in main.py when SAFETY-07
lands.
CFG-03: not widened
Zero new options. config.yaml and validate.py are untouched by this PR.
The soft limit already exists as peak_cap_w (default 3500), already validated,
already read by control.peak_at_risk(). §14 names a tariffs.capacity_peak_limit_kw: 2.5 - in kilowatts against watts everywhere
else - and adding it would have been a fourth defaults site and would have
imported the unit mismatch §14's own warning box says must be caught at startup. tariff.py takes its limit as a constructor argument in watts.
Part 10 pins it the way test_freeze.py pins meter_freeze_s: a Controller
built with peak_cap_wabsent must land on the number read out of config.yaml, so the shipped default and main.py's fallback cannot drift apart.
Note for TARIFF-02: the conflict we were both told to expect in config.yaml
and validate.py does not exist from this side - I changed neither file. The only
shared file I touched is app/p1.py, and only by appending snapshot()/restore() to QuarterAverager.
AC 3 is NOT met, and the check says so
AC 3 asks for the peak held under the soft limit in >= 95 % of intervals over a 30-day simulation replay. There is no 30-day dataset.sim/scenarios/
holds exactly two days of this house's real meter - 2026-08-20 (the Illusmart
failure day) and 2026-08-23 (the healthy reference) - exported 2026-08-25 because
they were about to age out of HA's recorder. The rest of the inventory is RS485
bus frames, OTA witness logs and a 578-row rollback poll: nothing else carries
grid power over calendar time.
Part 9 replays the two days that exist and reports:
p1-net-2026-08-20.jsonl.gz: 24.0 h, 95 closed quarters, month peak 5030 W,
94/95 = 98.9 % under the 3500 W soft limit
p1-net-2026-08-23.jsonl.gz: 24.0 h, 95 closed quarters, month peak 507 W,
95/95 = 100.0 % under the 3500 W soft limit
AC 3 MEASURED: 189/190 quarters = 99.5 % under the 3500 W soft limit, over
2.0 DAYS of replay, UNCONTROLLED (no peak shaving ran on either day).
AC 3 ASKS FOR 30 DAYS. 2.0 days exist.
Nothing in part 9 exits non-zero on the 95 % figure: a 2-day run cannot pass or
fail a 30-day criterion, and a check claiming it could would be the more expensive
mistake. Both days are uncontrolled, so the figure is the baseline SAFETY-07
has to improve on, which is the useful thing 2 days can say. The 2026-08-20 peak
of 5030 W is worth noting on its own: the failure day really did put a quarter over
the live install's 5000 W rating.
The two days are committed as scenarios/p1-net-*.jsonl.gz (~140 KB each) 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
CI cannot see is not a regression fixture. Closing AC 3 needs 30 days of capture -
TEST-01's harness - and that cannot start before somebody starts recording.
The checks, and the mutations they were sharpened against
test_tariff.py, ten parts, house style, ~2 s. Every verdict line is derived from
the fixture or from the object built, not from a literal. All ten existing suites
still pass.
Two traps were hit for real while writing it, and both are now documented in the
file:
The 900 s degeneracy.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. Part 2 shows them agreeing there - so the trap is documented
rather than asserted around - and then shows part 1's fixture separating all
three. Part 1's fixture is non-degenerate on three axes at once: irregular
spacing, a sample that STRADDLES :15 (split 20 s / 10 s across the boundary), and
a gap longer than max_hold_s so coverage is 130 s of 900.
The transition-hour degeneracy. The DST probes were originally at noon and at
3-hourly UTC steps. Both EU transitions happen at 01:00 UTC, so a rule wrong by
an hour differs from the right one only inside a one-hour window that both sweeps
stepped clean over - two mutations survived the entire file. Worse, the UTC probes
compared utcoffset() of the converted result, which routes back through the local arm and never graded fromutc() at all; they now compare the converted
wall clock. Hourly probes across the transition weekends and local probes at
03:00-03:59 on the October Sunday kill both. The March gap hour (02:00-02:59
local, which does not exist) is deliberately not graded, and the file says why.
Mutations run, all confirmed red:
mutation
caught by
divide a closed block by elapsed, not 900
part 1 worked example
attribute a straddling sample wholly to its first block
part 1 split
let export net off against import
part 1
ignore max_hold_s, hold a dead meter forward
parts 1, 2
compute the calendar month in UTC
part 4
never resume a checkpoint
part 3
resume but restamp the held sample to "now"
part 3
discard the stale block a restart found
part 4
let a re-bank LOWER a banked quarter / a month's peak
parts 3, 8
sync() without dedupe (recorded counts cycles, not quarters)
part 10
report a never-opened store as NO_RECORD
part 5
report a failed query as NO_RECORD
part 5
full headroom / 0.0 warning fraction when unobserved
part 5
default the per-phase reduction to SUM
part 7
project over elapsed instead of 900
part 2
EU DST rule off by an hour, local arm and UTC arm
part 6
EU DST rule: last Sunday -> first Sunday
part 6
CHECKPOINT_S raised past a whole quarter
part 3
Open, and deliberately left open
The per-phase reduction policy. §9.1 requires per-phase awareness but does
not say whether a three-phase connection is billed on the sum of the phases or
the worst one. It is one function, billed_w(block, policy), defaulting to NET
(the connection's own billed register). Part 7 runs it on an UNBALANCED load
where net/sum/max are three different numbers - (3000, 500, -200) gives
3300 / 3500 / 3000 W - and also shows a balanced load collapsing NET and SUM,
which is why a balanced fixture would have been worth nothing. Correcting the
policy is a one-line change plus a test parameter.
AC 3, as above.
SAFETY-07's card reading P1 against a comment demoting it to P3 is noted, not
resolved - not mine.
## What this is
The instrument the money guard will read. It measures the number the Belgian
capaciteitstarief bills on - the highest 15-minute average offtake of the calendar
month (Fluvius / Resa / Ores) - and writes it somewhere a restart cannot reach.
It files no claim, applies no clamp and commands nothing; that is SAFETY-07.
Per the PM comment of 2026-08-30 this is built in `goodwe_controller/app/`, not
in the `ems/` layout the card's functional notes name. Read `ems/tariff/...` as
`app/tariff.py`.
## AC 1 was already built and unwired - the honest scope
`p1.QuarterAverager` already computes AC 1 in full: clock-aligned 900 s blocks,
time-weighted over irregular telegram spacing, offtake-only, per-phase, with a
`max_hold_s` gap rule. `P1Ingest.blocks` was a 96-entry list in memory that a
restart erased. So this PR is the persistence and the monthly reduction, not a
second accumulator - a second one would be a second source of truth for the exact
quantity the money depends on. The 2026-08-27 backlog-audit comment on the card
called this correctly.
## Block vs rolling, and what settled it
**The billed unit is a clock-aligned 15-minute BLOCK.** The Description says
"rolling"; AC 1 says "15-min block average". §9.1 settles it twice over: it calls
the billed unit "the highest 15-minute average offtake", and its own measured case
- "one 4998 W quarter moved a monthly peak from 4832 -> 5013 W" - is stated *per
quarter*, an object a sliding window does not have. "Rolling" in the Description
means CONTINUOUSLY UPDATED (the open block's partial average is readable at every
sample), not a trailing-15-minutes window.
This is not a tie broken by preference. A trailing window reports a peak the meter
never bills, and it reports it **higher** than the real one: a load burst
straddling `:15` fills a trailing window completely while contributing to two
block averages at partial weight. Guarding that spends battery defending an
interval nobody charges for.
## Delivered power, not intended power (SAFETY-20)
SAFETY-20 has an open question about whether guards grade `Decision.target`
(pre-clamp intent) or `standing` (the wire value). For a capacity tariff there is
no question: the utility's meter integrates the current that flowed. `tariff.py`
never sees a setpoint at all - it consumes `P1Sample`, the meter's own reading.
A peak computed from intent would be wrong in the expensive direction: a clamped
9 kW request that delivered 5 kW would record 9 kW and every consumer would shave
against a peak that never existed.
This is asserted, not asserted-in-prose. `test_tariff.py` part 10 parses
`tariff.py` and checks that no name it evaluates is a command-side name
(`setpoint`, `target`, `Decision`, `i_clamped`, `clamped`, `Claim`, `P_PEAK`,
`resolve`, `arbiter`), and that it imports nothing from `arbiter`/`clamp`/
`control`. It is graded on the parsed **syntax** rather than by grep, because the
module docstring argues the point at length and names every forbidden word doing
so - a grep would be satisfied by deleting the explanation. The same grader is
then run over `main.py`, which DOES command, to prove it can fail.
## Persistence
SQLite at `/data/tariff.db`, two §15 tiers, and no `DELETE` against either
anywhere in the module (checked structurally in part 8):
| table | tier | retention |
|---|---|---|
| `quarter_blocks` | 15-min aggregates | indefinite |
| `monthly_peak` | monthly peak records | permanent |
`monthly_peak` is a **derived** record - upserted on each block close, and
`recompute_month()` re-derives it from `quarter_blocks` so the two are *proven*
equal rather than assumed to be. Part 5 also shows that dropping `monthly_peak`
loses nothing while the 15-min tier survives, which is the argument for keeping it
derived.
**Restart mid-block.** `QuarterAverager` grew a `snapshot()`/`restore()` pair; the
tracker checkpoints the open block every `CHECKPOINT_S = 15 s` (worst case
15/900 = 1.7 % of one quarter, computed from the constants in the check, not
quoted) and clears the checkpoint in the same breath as a block close. Three
cases, all exercised:
* checkpoint's block contains `now` -> resume verbatim, **including the held
sample's original timestamp**, so the outage is integrated under the same
`max_hold_s` rule a running process would apply. Uncovered seconds therefore
drag the closed block DOWN instead of being held forward across the outage;
* checkpoint's block ended while we were down -> that block really happened, so
it is banked at `acc / 900` (partial coverage, reads low) rather than discarded;
* no checkpoint, or one that does not fit (a `meter_phases` change) -> start clean
and **say which**. `restore()` returns the sentence, and the status page prints
it.
Double counting is answered structurally, not by care: `quarter_start` is the
PRIMARY KEY and a re-bank may only ever RAISE a figure
(`ON CONFLICT ... WHERE excluded.offtake_avg_w > ...`). A replay is a no-op; a
restart that reprocesses a quarter with less coverage cannot lower one already
banked.
**Month rollover.** Calendar months are LOCAL (Europe/Brussels); blocks are stored
UTC. A quarter starting `2026-08-31 22:15 UTC` bills in **September** - filing it
under August moves a month's bill. Block *alignment* still needs no timezone
(every Belgian offset is a whole number of hours). `zoneinfo` is used where
available with an encoded EU-rule fallback, graded against `zoneinfo` at 397 UTC
probes and 40 local-wall probes.
**An unreadable store is not a zero peak.** This is `sim/riglock.py`'s `holder()`
fix applied to something that spends money, and here the degenerate value is not
merely wrong, it is **disarming**: a peak read as 0 W hands SAFETY-07 the maximum
possible headroom and silently switches the guard off at the moment its own
storage is broken. So `peak()` answers `OBSERVED` / `NO_RECORD` / `NOT_OBSERVED`,
`peak_w` is `None` unless OBSERVED, and there is no accessor that defaults it - a
caller who forgets gets a `TypeError`. `headroom_w()` returns `None`, never
`cap_w`. Four failure paths land in `NOT_OBSERVED` and are each exercised: never
opened, no durable volume, a closed handle, and an open handle whose query fails.
## The HA-side overlap, named and decided
`peak_forecast_entity` + `peak_cap_w` already exist, read in
`Controller.read_inputs()` and consumed by `control.maintenance_charge_floor()` /
`peak_at_risk()`. That is the existing HA-side delegation the PM comment warns
about.
**Decision: kept, unchanged, and not duplicated.** Repointing
`maintenance_charge_floor()` at this measurement changes what moves energy during
a maintenance charge, which needs its own acceptance and belongs to SAFETY-07 - a
measurement ticket should not be the thing that alters a charge current. What this
PR adds is the **measured month peak**, which is a different quantity from a
forecast entity, and it is published as one status row, not as a second entity
shadowing the first. The swap is a one-line change in `main.py` when SAFETY-07
lands.
## CFG-03: not widened
**Zero new options. `config.yaml` and `validate.py` are untouched by this PR.**
The soft limit already exists as `peak_cap_w` (default 3500), already validated,
already read by `control.peak_at_risk()`. §14 names a
`tariffs.capacity_peak_limit_kw: 2.5` - in *kilo*watts against watts everywhere
else - and adding it would have been a fourth defaults site *and* would have
imported the unit mismatch §14's own warning box says must be caught at startup.
`tariff.py` takes its limit as a constructor argument in watts.
Part 10 pins it the way `test_freeze.py` pins `meter_freeze_s`: a `Controller`
built with `peak_cap_w` **absent** must land on the number read out of
`config.yaml`, so the shipped default and `main.py`'s fallback cannot drift apart.
**Note for TARIFF-02:** the conflict we were both told to expect in `config.yaml`
and `validate.py` does not exist from this side - I changed neither file. The only
shared file I touched is `app/p1.py`, and only by appending
`snapshot()`/`restore()` to `QuarterAverager`.
## AC 3 is NOT met, and the check says so
AC 3 asks for the peak held under the soft limit in >= 95 % of intervals over a
**30-day** simulation replay. **There is no 30-day dataset.** `sim/scenarios/`
holds exactly two days of this house's real meter - 2026-08-20 (the Illusmart
failure day) and 2026-08-23 (the healthy reference) - exported 2026-08-25 because
they were about to age out of HA's recorder. The rest of the inventory is RS485
bus frames, OTA witness logs and a 578-row rollback poll: nothing else carries
grid power over calendar time.
Part 9 replays the two days that exist and reports:
```
p1-net-2026-08-20.jsonl.gz: 24.0 h, 95 closed quarters, month peak 5030 W,
94/95 = 98.9 % under the 3500 W soft limit
p1-net-2026-08-23.jsonl.gz: 24.0 h, 95 closed quarters, month peak 507 W,
95/95 = 100.0 % under the 3500 W soft limit
AC 3 MEASURED: 189/190 quarters = 99.5 % under the 3500 W soft limit, over
2.0 DAYS of replay, UNCONTROLLED (no peak shaving ran on either day).
AC 3 ASKS FOR 30 DAYS. 2.0 days exist.
```
Nothing in part 9 exits non-zero on the 95 % figure: a 2-day run cannot pass or
fail a 30-day criterion, and a check claiming it could would be the more expensive
mistake. Both days are *uncontrolled*, so the figure is the **baseline SAFETY-07
has to improve on**, which is the useful thing 2 days can say. The 2026-08-20 peak
of 5030 W is worth noting on its own: the failure day really did put a quarter over
the live install's 5000 W rating.
The two days are committed as `scenarios/p1-net-*.jsonl.gz` (~140 KB each) 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
CI cannot see is not a regression fixture. Closing AC 3 needs 30 days of capture -
TEST-01's harness - and that cannot start before somebody starts recording.
## The checks, and the mutations they were sharpened against
`test_tariff.py`, ten parts, house style, ~2 s. Every verdict line is derived from
the fixture or from the object built, not from a literal. All ten existing suites
still pass.
Two traps were hit for real while writing it, and both are now documented in the
file:
**The 900 s degeneracy.** `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. Part 2 shows them agreeing there - so the trap is documented
rather than asserted around - and then shows part 1's fixture separating all
three. Part 1's fixture is non-degenerate on three axes at once: irregular
spacing, a sample that STRADDLES `:15` (split 20 s / 10 s across the boundary), and
a gap longer than `max_hold_s` so coverage is 130 s of 900.
**The transition-hour degeneracy.** The DST probes were originally at noon and at
3-hourly UTC steps. Both EU transitions happen at 01:00 UTC, so a rule wrong *by
an hour* differs from the right one only inside a one-hour window that both sweeps
stepped clean over - two mutations survived the entire file. Worse, the UTC probes
compared `utcoffset()` of the converted result, which routes back through the
*local* arm and never graded `fromutc()` at all; they now compare the converted
wall clock. Hourly probes across the transition weekends and local probes at
03:00-03:59 on the October Sunday kill both. The March gap hour (02:00-02:59
local, which does not exist) is deliberately not graded, and the file says why.
Mutations run, all confirmed red:
| mutation | caught by |
|---|---|
| divide a closed block by `elapsed`, not 900 | part 1 worked example |
| attribute a straddling sample wholly to its first block | part 1 split |
| let export net off against import | part 1 |
| ignore `max_hold_s`, hold a dead meter forward | parts 1, 2 |
| compute the calendar month in UTC | part 4 |
| never resume a checkpoint | part 3 |
| resume but restamp the held sample to "now" | part 3 |
| discard the stale block a restart found | part 4 |
| let a re-bank LOWER a banked quarter / a month's peak | parts 3, 8 |
| `sync()` without dedupe (recorded counts cycles, not quarters) | part 10 |
| report a never-opened store as `NO_RECORD` | part 5 |
| report a failed query as `NO_RECORD` | part 5 |
| full headroom / `0.0` warning fraction when unobserved | part 5 |
| default the per-phase reduction to SUM | part 7 |
| project over `elapsed` instead of 900 | part 2 |
| EU DST rule off by an hour, local arm and UTC arm | part 6 |
| EU DST rule: last Sunday -> first Sunday | part 6 |
| `CHECKPOINT_S` raised past a whole quarter | part 3 |
## Open, and deliberately left open
* **The per-phase reduction policy.** §9.1 requires per-phase awareness but does
not say whether a three-phase connection is billed on the sum of the phases or
the worst one. It is one function, `billed_w(block, policy)`, defaulting to NET
(the connection's own billed register). Part 7 runs it on an UNBALANCED load
where net/sum/max are three different numbers - `(3000, 500, -200)` gives
3300 / 3500 / 3000 W - and also shows a balanced load collapsing NET and SUM,
which is why a balanced fixture would have been worth nothing. Correcting the
policy is a one-line change plus a test parameter.
* **AC 3**, as above.
* SAFETY-07's card reading P1 against a comment demoting it to P3 is noted, not
resolved - not mine.
The 15-minute block average was already built and unwired. p1.QuarterAverager
computes clock-aligned 900 s blocks, time-weighted over irregular telegram
spacing, offtake-only and per-phase; P1Ingest.blocks was a 96-entry list in
memory that a restart erased. So this is the persistence and the monthly
reduction, not a second accumulator - a second one would be a second source of
truth for the exact quantity the money depends on.
BLOCKS, NOT A SLIDING WINDOW. The card says "rolling"; 9.1 bills "the highest
15-minute average offtake" and states its own worked case per QUARTER - one
4998 W quarter moving a monthly peak from 4832 to 5013 W. A trailing window
reports a peak the meter never bills, and reports it HIGH: a burst straddling
:15 fills the window completely while contributing to two block averages at
partial weight.
DELIVERED, NOT INTENDED. tariff.py only ever sees P1Sample - the meter's own
reading - and never a setpoint. SAFETY-20's open question does not arise for a
capacity tariff: the utility integrates the current that flowed.
AN UNREADABLE STORE IS NOT A ZERO PEAK. peak() answers OBSERVED / NO_RECORD /
NOT_OBSERVED with peak_w None unless observed, which is riglock.holder()'s fix
applied to something that spends money. A peak read as 0 W hands SAFETY-07
maximum headroom and disarms the guard at the moment its own storage breaks.
Restart mid-block resumes the accumulator through a new snapshot()/restore()
pair, checkpointed every 15 s and cleared in the same breath as a block close.
The held sample keeps its ORIGINAL timestamp, so an outage is integrated under
the same max_hold_s rule a running process would apply. quarter_start is the
primary key and a re-bank may only ever RAISE a figure.
Calendar months are LOCAL: a quarter starting 22:15 UTC on 31 August bills in
September.
No new option (the limit is the existing peak_cap_w), so config.yaml and
validate.py are untouched and CFG-03 is not widened. No second peak entity:
peak_forecast_entity stays the input to maintenance_charge_floor(), because
repointing that consumer changes what moves energy and belongs to SAFETY-07.
AC 3 is NOT met. It asks for 30 days of replay; two days of this house's real
meter exist and are committed here. test_tariff.py part 9 replays them, reports
the percentage next to the sample size in days, and asserts nothing about 95 %.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Reviewed and merging. The scope claim holds, the block-vs-rolling reading is provably right, and I found the third degeneracy.
Own detached worktree at f4914a1, own harness, own probes. All ten suites green as shipped. CI verified by me, not taken on report: checks / unit (pull_request) -> success, Successful in 2m38s, run 537 / job 1532, combined state success.
1. "AC 1 was already built" — TRUE, checked on the base, not on your word
Read QuarterAverager out of 9aadf46 (pre-PR). It already does, in the shipped source:
claim
on the base
clock-aligned 900 s blocks
int(t // QUARTER_S) * QUARTER_S
time-weighted over irregular spacing
self._acc += max(net, 0.0) * covered
offtake only
max(self._last_net, 0.0)
per-phase
_pp_acc, per_phase_offtake_avg_w
max_hold_s gap rule
hold_end = self._last_t + self.max_hold_s
straddle split at the boundary
the while loop, stop = min(t, end)
closed block ÷ the full 900 s
offtake_avg_w=self._acc / QUARTER_S
And P1Ingest.blocks really was del self.blocks[:-96] — a day of quarters in memory. Your p1.py diff is 0 removed lines, one appended hunk. So the premise the whole design rests on is sound: building persistence and reduction on top of the existing accumulator, rather than a second one, is the right call and it is what you did.
2. Block vs rolling — right, and stronger than you argued it
I built the worst case rather than reasoning about it: a 900 s burst of 4000 W centred exactly on a :15 boundary, replayed through the shipped QuarterAverager.
highest BILLED block average : 2000.0 W per-block: [0, 2000, 2000, 0]
highest trailing-window mean : 4000.0 W
the rolling figure is 2.00x the number the meter bills
Exactly 2.00x, and that is the supremum — a 900 s burst can be split at worst 50/50 across two blocks. On the real captures the same comparison gives 1.09x (2026-08-20) and 1.05x (2026-08-23).
And the asymmetry is not a tendency, it is a theorem: the clock-aligned windows are a subset of all trailing windows, so a trailing maximum can only ever be >= the block maximum. A rolling guard therefore cannot read low and can read up to twice high — it spends battery defending an interval nobody bills, and never under-defends the one they do. That is worth stating as the one-directional result it is rather than as "reports it high".
Your §9.1 reading is also right on its own terms: the section names the billed unit and states its measured case per quarter. Settled.
3. The SAFETY-20 grader — it discriminates, with two evasions in the import half
The load-bearing half works. G1, adding def _leak(ctl): return ctl.target to tariff.py:
FAIL none of them is a command-side name [...] ; found ['target']
Grading the parsed syntax rather than grepping is the right call, and running it over main.py to prove it can fail is right too.
Two evasions, both in the import check:
G2 from . import arbiter as _ab -> GREEN (ImportFrom with module=None; "" is not in the list,
and `arbiter` is an alias, not a Name node)
G3 import app.arbiter -> GREEN (an ast.Import node - the grader only walks ImportFrom)
Low severity, because the _names half still catches any use (_ab.resolve(...) gives resolve as an Attribute.attr), so an evader would have to import the module and touch only names off the list. But the check's own sentence says "it imports nothing from arbiter, clamp or control", and that sentence is false for two of the three import forms. Walking ast.Import and reading alias.name when ImportFrom.module is None closes both.
4. DST — correct, and the fix genuinely closes the class that escaped
I graded _EUTimezone against zoneinfo myself, at minute resolution, comparing converted wall clock on the UTC arm and utcoffset() on the local arm:
8 transitions, 2025-2028, +-3 h at 1-minute steps ....... 0 mismatches
all of 2026, hourly ..................................... 0 mismatches
every 2026 month boundary, +-90 min at 1-minute steps ... 0 mismatches
local arm across the October fold and the March gap ..... 0 mismatches
And the check bites, including the exact class that survived before:
D1 _dst_utc off by an hour ..................... RED (1)
D3 last Sunday -> first Sunday ................. RED (3)
D6 last Sunday -> last Saturday ................ RED (3)
D5 fromutc() uses the LOCAL arm ................ RED (1) <- mine
D5 is the wrong-arm bug: it differs from correct only inside the one-hour transition window, which is precisely what the noon-and-3-hourly probes stepped over. The hourly-across-the-weekend rewrite kills it. The fix is real, not cosmetic.
Month bucketing: M1 (compute the month in UTC) is RED on 4 assertions, and M2 — mine, bucketing by the block's end instead of its start, which differs only for the single last block of a month — is RED on 1. That boundary is covered.
5. THE THIRD DEGENERACY, and it is on the seam the pending product decision will land on
You found the 900 s trap and the transition-hour trap. Here is the third, same shape:
billed_w() is graded on an unbalanced fixture. record_block()'s use of it is not. I hardcoded policy = POLICY_SUM inside record_block, ignoring its argument entirely — the whole file stays GREEN.
It is not an inert mutation. Pushing one unbalanced block through the real store:
an UNBALANCED block: net 3300 W, phases (3000, 500, 0)
STORED under policy='net' -> 3300 W
STORED under policy='sum' -> 3500 W
STORED under policy='max' -> 3000 W
So record_block really does vary with the policy — it survives because every block that reaches the store in the check is single-phase or per_phase_offtake_avg_w=None, exactly where NET, SUM and MAX collapse to one number. Part 7's unbalanced load (3000, 500, -200) is fed to billed_w() directly and never to the store.
That matters more than a normal coverage gap: the per-phase policy is the open product question, so record_block's policy argument is the line most likely to be edited next, and it is the one line nothing grades. Two extra lines in part 7 — store the same unbalanced block under two policies, assert the two stored figures differ — close it.
6. Persistence — all four behaviours produced, not read
1 restart mid-block: held sample keeps its ORIGINAL timestamp (+300 s, not 'now')
block after a 400 s outage 2355.6 W vs 4000.0 W with no outage
-> the outage DRAGS THE BLOCK DOWN, and is not discarded either
2 a block that ended while down: banked at acc/900 = 1333 W, NOT acc/elapsed = 4000 W
3 never-openable store -> NOT_OBSERVED, peak_w=None, headroom_w()=None (not 3500.0)
openable but empty -> NO_RECORD, distinct, peak_w=None
OPEN handle, query fails -> NOT_OBSERVED, not NO_RECORD <- the TEST-04 shape
4 PeakReading.state has NO default: a variant must be chosen explicitly
peak_w has no non-None default; arithmetic on an unobserved peak raises TypeError
5 recompute_month() == the stored monthly_peak, and it is the largest block not the last
6 no DELETE against either tier
Both TEST-04 survivors are answered: there is no defaulted variant field, and a live handle whose query fails is NOT_OBSERVED rather than being taken as proof of life.
One thing you did not claim and I checked anyway: nothing calls peak_store.close() on shutdown (self.mqtt.close() is there, this is not). It costs nothing — a child process that writes a block and a checkpoint and then os._exit(9)s, with no close, no atexit and no GC, loses neither:
after the hard death, reopened: peak state=observed peak_w=4321.0
checkpoint survived: True
Durable by commit, not by cleanup. Worth a line in the module note so the absence reads as deliberate.
7. Mutations — red and green
Red:G1 grader/obvious command name (1) · D1 UTC arm off by an hour (1) · D3 first-Sunday (3) · D5fromutc on the local arm (1) · D6 last-Saturday (3) · M1 month in UTC (4) · M2 bucketed by block end (1) · N3 headroom sign flipped (1).
Green — four survivors:
N2record_block ignores its policy — section 5. The one worth acting on.
G2 / G3 import-guard evasions — section 3.
N7is_observed returns True for NO_RECORD — nothing pins the predicate's discrimination between the two answering states. headroom_w() checks NO_RECORD first so today it is harmless, but the whole point of the three-state design is that these never collapse, and the predicate that says so is ungraded.
N4clear_partial() on block close removed — and this one is a pass, not a hole: it survives because the PRIMARY KEY + ON CONFLICT ... WHERE excluded > existing guard makes a replay a no-op. That is your "double counting is answered structurally, not by care" argument, demonstrated by mutation. Worth keeping in mind only because if that WHERE clause is ever relaxed, nothing catches the pair.
8. AC 3 — honest partial, not a blocker. My ruling.
Accept it as stated, and do not let the card close as AC 3 met.
The dataset genuinely does not exist — I looked; scenarios/ holds the two days and nothing else carries grid power over calendar time. Nothing in part 9 exits non-zero on the 95 % figure, which is the right call: a 2-day run can neither pass nor fail a 30-day criterion, and a check that pretended otherwise would be the more expensive error. What the two days do say — the uncontrolled baseline SAFETY-07 has to beat — is the useful thing available, and it is labelled as exactly that.
Every number reproduces on my own replay through the shipped averager:
The 5030 W is confirmed: 5029.8 W. The failure day put a billed quarter over the live install's own 5000 W rating. That is worth the product owner's attention on its own, and it is the strongest single argument for SAFETY-07 existing.
Closing AC 3 needs 30 days that cannot be captured retroactively, so the useful action is to start recording now rather than to hold this PR.
9. The two nits
project() reading averager._last_net — worth fixing, and it is worse than "a private attribute". It is getattr(averager, "_last_net", 0.0): rename that field in p1.py and the projection silently starts crediting 0 W held for the remainder of the quarter, i.e. it reads low, i.e. SAFETY-07 under-guards and nothing raises. The default is what makes it silent. A last_net_w property on QuarterAverager plus dropping the default turns a silent wrong answer into an AttributeError. You already own that class this PR. Not blocking — nothing consumes project() yet — but do it before SAFETY-07 reads it.
_seen unbounded — 96 entries a day, a set of strings. Not blocking, not worth a commit on its own.
10. Housekeeping
Your zero-conflict claim against TARIFF-02 (#16) is confirmed. The only overlap is CHANGELOG.md, both prepending a stanza. #16 touches config.yaml, validate.py, DOCS.md, app/price.py, test_price.py; #17 touches none of those, and #16 touches neither main.py nor p1.py. Either order works and only the changelog rebases. I am landing #17 first because SAFETY-07 is blocked on this measurement existing; #16 is unaffected either way.
Merges clean into the current release/1.0.
The per-phase policy is correctly left open and is with the product owner. Add the record_block coverage from section 5 now regardless — whatever the answer, that is the line that changes.
I ran nothing against the rig, the live add-on, the inverter or firmware.
Merging.
## Reviewed and merging. The scope claim holds, the block-vs-rolling reading is provably right, and I found the third degeneracy.
Own detached worktree at `f4914a1`, own harness, own probes. All ten suites green as shipped. CI verified by me, not taken on report: `checks / unit (pull_request) -> success, Successful in 2m38s`, run 537 / job 1532, combined state `success`.
---
## 1. "AC 1 was already built" — TRUE, checked on the base, not on your word
Read `QuarterAverager` out of `9aadf46` (pre-PR). It already does, in the shipped source:
| claim | on the base |
|---|---|
| clock-aligned 900 s blocks | `int(t // QUARTER_S) * QUARTER_S` |
| time-weighted over irregular spacing | `self._acc += max(net, 0.0) * covered` |
| offtake only | `max(self._last_net, 0.0)` |
| per-phase | `_pp_acc`, `per_phase_offtake_avg_w` |
| `max_hold_s` gap rule | `hold_end = self._last_t + self.max_hold_s` |
| straddle split at the boundary | the `while` loop, `stop = min(t, end)` |
| closed block ÷ the full 900 s | `offtake_avg_w=self._acc / QUARTER_S` |
And `P1Ingest.blocks` really was `del self.blocks[:-96]` — a day of quarters in memory. Your `p1.py` diff is **0 removed lines**, one appended hunk. So the premise the whole design rests on is sound: building persistence and reduction on top of the existing accumulator, rather than a second one, is the right call and it is what you did.
## 2. Block vs rolling — right, and stronger than you argued it
I built the worst case rather than reasoning about it: a 900 s burst of 4000 W centred exactly on a `:15` boundary, replayed through the shipped `QuarterAverager`.
```
highest BILLED block average : 2000.0 W per-block: [0, 2000, 2000, 0]
highest trailing-window mean : 4000.0 W
the rolling figure is 2.00x the number the meter bills
```
**Exactly 2.00x, and that is the supremum** — a 900 s burst can be split at worst 50/50 across two blocks. On the real captures the same comparison gives 1.09x (2026-08-20) and 1.05x (2026-08-23).
And the asymmetry is not a tendency, it is a theorem: **the clock-aligned windows are a subset of all trailing windows, so a trailing maximum can only ever be >= the block maximum.** A rolling guard therefore cannot read low and can read up to twice high — it spends battery defending an interval nobody bills, and never under-defends the one they do. That is worth stating as the one-directional result it is rather than as "reports it high".
Your §9.1 reading is also right on its own terms: the section names the billed unit and states its measured case *per quarter*. Settled.
## 3. The SAFETY-20 grader — it discriminates, with two evasions in the import half
The load-bearing half works. `G1`, adding `def _leak(ctl): return ctl.target` to `tariff.py`:
```
FAIL none of them is a command-side name [...] ; found ['target']
```
Grading the parsed syntax rather than grepping is the right call, and running it over `main.py` to prove it can fail is right too.
**Two evasions, both in the import check:**
```
G2 from . import arbiter as _ab -> GREEN (ImportFrom with module=None; "" is not in the list,
and `arbiter` is an alias, not a Name node)
G3 import app.arbiter -> GREEN (an ast.Import node - the grader only walks ImportFrom)
```
Low severity, because the `_names` half still catches any *use* (`_ab.resolve(...)` gives `resolve` as an `Attribute.attr`), so an evader would have to import the module and touch only names off the list. But the check's own sentence says "it imports nothing from arbiter, clamp or control", and that sentence is false for two of the three import forms. Walking `ast.Import` and reading `alias.name` when `ImportFrom.module` is `None` closes both.
## 4. DST — correct, and the fix genuinely closes the class that escaped
I graded `_EUTimezone` against `zoneinfo` myself, at minute resolution, comparing converted wall clock on the UTC arm and `utcoffset()` on the local arm:
```
8 transitions, 2025-2028, +-3 h at 1-minute steps ....... 0 mismatches
all of 2026, hourly ..................................... 0 mismatches
every 2026 month boundary, +-90 min at 1-minute steps ... 0 mismatches
local arm across the October fold and the March gap ..... 0 mismatches
```
And the check bites, including the exact class that survived before:
```
D1 _dst_utc off by an hour ..................... RED (1)
D3 last Sunday -> first Sunday ................. RED (3)
D6 last Sunday -> last Saturday ................ RED (3)
D5 fromutc() uses the LOCAL arm ................ RED (1) <- mine
```
`D5` is the wrong-arm bug: it differs from correct **only inside the one-hour transition window**, which is precisely what the noon-and-3-hourly probes stepped over. The hourly-across-the-weekend rewrite kills it. The fix is real, not cosmetic.
Month bucketing: `M1` (compute the month in UTC) is RED on 4 assertions, and `M2` — mine, bucketing by the block's **end** instead of its start, which differs only for the single last block of a month — is RED on 1. That boundary is covered.
## 5. THE THIRD DEGENERACY, and it is on the seam the pending product decision will land on
You found the 900 s trap and the transition-hour trap. Here is the third, same shape:
**`billed_w()` is graded on an unbalanced fixture. `record_block()`'s use of it is not.** I hardcoded `policy = POLICY_SUM` inside `record_block`, ignoring its argument entirely — **the whole file stays GREEN.**
It is not an inert mutation. Pushing one unbalanced block through the real store:
```
an UNBALANCED block: net 3300 W, phases (3000, 500, 0)
STORED under policy='net' -> 3300 W
STORED under policy='sum' -> 3500 W
STORED under policy='max' -> 3000 W
```
So `record_block` really does vary with the policy — it survives because **every block that reaches the store in the check is single-phase or `per_phase_offtake_avg_w=None`**, exactly where NET, SUM and MAX collapse to one number. Part 7's unbalanced load `(3000, 500, -200)` is fed to `billed_w()` directly and never to the store.
That matters more than a normal coverage gap: **the per-phase policy is the open product question, so `record_block`'s policy argument is the line most likely to be edited next, and it is the one line nothing grades.** Two extra lines in part 7 — store the same unbalanced block under two policies, assert the two stored figures differ — close it.
## 6. Persistence — all four behaviours produced, not read
```
1 restart mid-block: held sample keeps its ORIGINAL timestamp (+300 s, not 'now')
block after a 400 s outage 2355.6 W vs 4000.0 W with no outage
-> the outage DRAGS THE BLOCK DOWN, and is not discarded either
2 a block that ended while down: banked at acc/900 = 1333 W, NOT acc/elapsed = 4000 W
3 never-openable store -> NOT_OBSERVED, peak_w=None, headroom_w()=None (not 3500.0)
openable but empty -> NO_RECORD, distinct, peak_w=None
OPEN handle, query fails -> NOT_OBSERVED, not NO_RECORD <- the TEST-04 shape
4 PeakReading.state has NO default: a variant must be chosen explicitly
peak_w has no non-None default; arithmetic on an unobserved peak raises TypeError
5 recompute_month() == the stored monthly_peak, and it is the largest block not the last
6 no DELETE against either tier
```
Both TEST-04 survivors are answered: there is no defaulted variant field, and a *live handle whose query fails* is `NOT_OBSERVED` rather than being taken as proof of life.
**One thing you did not claim and I checked anyway:** nothing calls `peak_store.close()` on shutdown (`self.mqtt.close()` is there, this is not). It costs nothing — a child process that writes a block and a checkpoint and then `os._exit(9)`s, with no close, no atexit and no GC, loses neither:
```
after the hard death, reopened: peak state=observed peak_w=4321.0
checkpoint survived: True
```
Durable by commit, not by cleanup. Worth a line in the module note so the absence reads as deliberate.
## 7. Mutations — red and green
**Red:** `G1` grader/obvious command name (1) · `D1` UTC arm off by an hour (1) · `D3` first-Sunday (3) · `D5` `fromutc` on the local arm (1) · `D6` last-Saturday (3) · `M1` month in UTC (4) · `M2` bucketed by block end (1) · `N3` headroom sign flipped (1).
**Green — four survivors:**
- **`N2` `record_block` ignores its policy** — section 5. The one worth acting on.
- **`G2` / `G3` import-guard evasions** — section 3.
- **`N7` `is_observed` returns True for `NO_RECORD`** — nothing pins the predicate's discrimination between the two answering states. `headroom_w()` checks `NO_RECORD` first so today it is harmless, but the whole point of the three-state design is that these never collapse, and the predicate that says so is ungraded.
- **`N4` `clear_partial()` on block close removed** — and this one is a *pass*, not a hole: it survives because the `PRIMARY KEY` + `ON CONFLICT ... WHERE excluded > existing` guard makes a replay a no-op. That is your "double counting is answered structurally, not by care" argument, demonstrated by mutation. Worth keeping in mind only because if that WHERE clause is ever relaxed, nothing catches the pair.
## 8. AC 3 — honest partial, not a blocker. My ruling.
**Accept it as stated, and do not let the card close as AC 3 met.**
The dataset genuinely does not exist — I looked; `scenarios/` holds the two days and nothing else carries grid power over calendar time. Nothing in part 9 exits non-zero on the 95 % figure, which is the right call: a 2-day run can neither pass nor fail a 30-day criterion, and a check that pretended otherwise would be the more expensive error. What the two days *do* say — the uncontrolled baseline SAFETY-07 has to beat — is the useful thing available, and it is labelled as exactly that.
Every number reproduces on my own replay through the shipped averager:
```
2026-08-20: 95 quarters, month peak 5029.8 W, 94/95 = 98.9 % under 3500 W
2026-08-23: 95 quarters, month peak 507.0 W, 95/95 = 100.0 %
combined : 189/190 = 99.5 %, uncontrolled
```
**The 5030 W is confirmed: 5029.8 W.** The failure day put a billed quarter over the live install's own 5000 W rating. That is worth the product owner's attention on its own, and it is the strongest single argument for SAFETY-07 existing.
Closing AC 3 needs 30 days that cannot be captured retroactively, so the useful action is to **start recording now** rather than to hold this PR.
## 9. The two nits
- **`project()` reading `averager._last_net` — worth fixing, and it is worse than "a private attribute".** It is `getattr(averager, "_last_net", 0.0)`: rename that field in `p1.py` and the projection silently starts crediting **0 W** held for the remainder of the quarter, i.e. it reads **low**, i.e. SAFETY-07 under-guards and nothing raises. The default is what makes it silent. A `last_net_w` property on `QuarterAverager` plus dropping the default turns a silent wrong answer into an `AttributeError`. You already own that class this PR. Not blocking — nothing consumes `project()` yet — but do it before SAFETY-07 reads it.
- **`_seen` unbounded** — 96 entries a day, a set of strings. Not blocking, not worth a commit on its own.
## 10. Housekeeping
- **Your zero-conflict claim against TARIFF-02 (#16) is confirmed.** The only overlap is `CHANGELOG.md`, both prepending a stanza. #16 touches `config.yaml`, `validate.py`, `DOCS.md`, `app/price.py`, `test_price.py`; #17 touches none of those, and #16 touches neither `main.py` nor `p1.py`. Either order works and only the changelog rebases. I am landing #17 first because SAFETY-07 is blocked on this measurement existing; #16 is unaffected either way.
- Merges clean into the current `release/1.0`.
- The per-phase policy is correctly left open and is with the product owner. Add the `record_block` coverage from section 5 now regardless — whatever the answer, that is the line that changes.
- I ran nothing against the rig, the live add-on, the inverter or firmware.
Merging.
admin
merged commit 0dbd485b5a into release/1.02026-08-30 19:33:58 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this is
The instrument the money guard will read. It measures the number the Belgian
capaciteitstarief bills on - the highest 15-minute average offtake of the calendar
month (Fluvius / Resa / Ores) - and writes it somewhere a restart cannot reach.
It files no claim, applies no clamp and commands nothing; that is SAFETY-07.
Per the PM comment of 2026-08-30 this is built in
goodwe_controller/app/, notin the
ems/layout the card's functional notes name. Readems/tariff/...asapp/tariff.py.AC 1 was already built and unwired - the honest scope
p1.QuarterAverageralready computes AC 1 in full: clock-aligned 900 s blocks,time-weighted over irregular telegram spacing, offtake-only, per-phase, with a
max_hold_sgap rule.P1Ingest.blockswas a 96-entry list in memory that arestart erased. So this PR is the persistence and the monthly reduction, not a
second accumulator - a second one would be a second source of truth for the exact
quantity the money depends on. The 2026-08-27 backlog-audit comment on the card
called this correctly.
Block vs rolling, and what settled it
The billed unit is a clock-aligned 15-minute BLOCK. The Description says
"rolling"; AC 1 says "15-min block average". §9.1 settles it twice over: it calls
the billed unit "the highest 15-minute average offtake", and its own measured case
quarter, an object a sliding window does not have. "Rolling" in the Description
means CONTINUOUSLY UPDATED (the open block's partial average is readable at every
sample), not a trailing-15-minutes window.
This is not a tie broken by preference. A trailing window reports a peak the meter
never bills, and it reports it higher than the real one: a load burst
straddling
:15fills a trailing window completely while contributing to twoblock averages at partial weight. Guarding that spends battery defending an
interval nobody charges for.
Delivered power, not intended power (SAFETY-20)
SAFETY-20 has an open question about whether guards grade
Decision.target(pre-clamp intent) or
standing(the wire value). For a capacity tariff there isno question: the utility's meter integrates the current that flowed.
tariff.pynever sees a setpoint at all - it consumes
P1Sample, the meter's own reading.A peak computed from intent would be wrong in the expensive direction: a clamped
9 kW request that delivered 5 kW would record 9 kW and every consumer would shave
against a peak that never existed.
This is asserted, not asserted-in-prose.
test_tariff.pypart 10 parsestariff.pyand checks that no name it evaluates is a command-side name(
setpoint,target,Decision,i_clamped,clamped,Claim,P_PEAK,resolve,arbiter), and that it imports nothing fromarbiter/clamp/control. It is graded on the parsed syntax rather than by grep, because themodule docstring argues the point at length and names every forbidden word doing
so - a grep would be satisfied by deleting the explanation. The same grader is
then run over
main.py, which DOES command, to prove it can fail.Persistence
SQLite at
/data/tariff.db, two §15 tiers, and noDELETEagainst eitheranywhere in the module (checked structurally in part 8):
quarter_blocksmonthly_peakmonthly_peakis a derived record - upserted on each block close, andrecompute_month()re-derives it fromquarter_blocksso the two are provenequal rather than assumed to be. Part 5 also shows that dropping
monthly_peakloses nothing while the 15-min tier survives, which is the argument for keeping it
derived.
Restart mid-block.
QuarterAveragergrew asnapshot()/restore()pair; thetracker checkpoints the open block every
CHECKPOINT_S = 15 s(worst case15/900 = 1.7 % of one quarter, computed from the constants in the check, not
quoted) and clears the checkpoint in the same breath as a block close. Three
cases, all exercised:
now-> resume verbatim, including the heldsample's original timestamp, so the outage is integrated under the same
max_hold_srule a running process would apply. Uncovered seconds thereforedrag the closed block DOWN instead of being held forward across the outage;
it is banked at
acc / 900(partial coverage, reads low) rather than discarded;meter_phaseschange) -> start cleanand say which.
restore()returns the sentence, and the status page printsit.
Double counting is answered structurally, not by care:
quarter_startis thePRIMARY KEY and a re-bank may only ever RAISE a figure
(
ON CONFLICT ... WHERE excluded.offtake_avg_w > ...). A replay is a no-op; arestart that reprocesses a quarter with less coverage cannot lower one already
banked.
Month rollover. Calendar months are LOCAL (Europe/Brussels); blocks are stored
UTC. A quarter starting
2026-08-31 22:15 UTCbills in September - filing itunder August moves a month's bill. Block alignment still needs no timezone
(every Belgian offset is a whole number of hours).
zoneinfois used whereavailable with an encoded EU-rule fallback, graded against
zoneinfoat 397 UTCprobes and 40 local-wall probes.
An unreadable store is not a zero peak. This is
sim/riglock.py'sholder()fix applied to something that spends money, and here the degenerate value is not
merely wrong, it is disarming: a peak read as 0 W hands SAFETY-07 the maximum
possible headroom and silently switches the guard off at the moment its own
storage is broken. So
peak()answersOBSERVED/NO_RECORD/NOT_OBSERVED,peak_wisNoneunless OBSERVED, and there is no accessor that defaults it - acaller who forgets gets a
TypeError.headroom_w()returnsNone, nevercap_w. Four failure paths land inNOT_OBSERVEDand are each exercised: neveropened, no durable volume, a closed handle, and an open handle whose query fails.
The HA-side overlap, named and decided
peak_forecast_entity+peak_cap_walready exist, read inController.read_inputs()and consumed bycontrol.maintenance_charge_floor()/peak_at_risk(). That is the existing HA-side delegation the PM comment warnsabout.
Decision: kept, unchanged, and not duplicated. Repointing
maintenance_charge_floor()at this measurement changes what moves energy duringa maintenance charge, which needs its own acceptance and belongs to SAFETY-07 - a
measurement ticket should not be the thing that alters a charge current. What this
PR adds is the measured month peak, which is a different quantity from a
forecast entity, and it is published as one status row, not as a second entity
shadowing the first. The swap is a one-line change in
main.pywhen SAFETY-07lands.
CFG-03: not widened
Zero new options.
config.yamlandvalidate.pyare untouched by this PR.The soft limit already exists as
peak_cap_w(default 3500), already validated,already read by
control.peak_at_risk(). §14 names atariffs.capacity_peak_limit_kw: 2.5- in kilowatts against watts everywhereelse - and adding it would have been a fourth defaults site and would have
imported the unit mismatch §14's own warning box says must be caught at startup.
tariff.pytakes its limit as a constructor argument in watts.Part 10 pins it the way
test_freeze.pypinsmeter_freeze_s: aControllerbuilt with
peak_cap_wabsent must land on the number read out ofconfig.yaml, so the shipped default andmain.py's fallback cannot drift apart.Note for TARIFF-02: the conflict we were both told to expect in
config.yamland
validate.pydoes not exist from this side - I changed neither file. The onlyshared file I touched is
app/p1.py, and only by appendingsnapshot()/restore()toQuarterAverager.AC 3 is NOT met, and the check says so
AC 3 asks for the peak held under the soft limit in >= 95 % of intervals over a
30-day simulation replay. There is no 30-day dataset.
sim/scenarios/holds exactly two days of this house's real meter - 2026-08-20 (the Illusmart
failure day) and 2026-08-23 (the healthy reference) - exported 2026-08-25 because
they were about to age out of HA's recorder. The rest of the inventory is RS485
bus frames, OTA witness logs and a 578-row rollback poll: nothing else carries
grid power over calendar time.
Part 9 replays the two days that exist and reports:
Nothing in part 9 exits non-zero on the 95 % figure: a 2-day run cannot pass or
fail a 30-day criterion, and a check claiming it could would be the more expensive
mistake. Both days are uncontrolled, so the figure is the baseline SAFETY-07
has to improve on, which is the useful thing 2 days can say. The 2026-08-20 peak
of 5030 W is worth noting on its own: the failure day really did put a quarter over
the live install's 5000 W rating.
The two days are committed as
scenarios/p1-net-*.jsonl.gz(~140 KB each) for thereason
scenarios/p1-age-2026-08-26.jsonl.gzalready is: the infra repo isprivate, CI's token is minted for this repository only, and a regression fixture
CI cannot see is not a regression fixture. Closing AC 3 needs 30 days of capture -
TEST-01's harness - and that cannot start before somebody starts recording.
The checks, and the mutations they were sharpened against
test_tariff.py, ten parts, house style, ~2 s. Every verdict line is derived fromthe fixture or from the object built, not from a literal. All ten existing suites
still pass.
Two traps were hit for real while writing it, and both are now documented in the
file:
The 900 s degeneracy.
acc/elapsed,acc/900and the end-of-quarterprojection are three different formulas that all agree at exactly full coverage of
a complete block. Part 2 shows them agreeing there - so the trap is documented
rather than asserted around - and then shows part 1's fixture separating all
three. Part 1's fixture is non-degenerate on three axes at once: irregular
spacing, a sample that STRADDLES
:15(split 20 s / 10 s across the boundary), anda gap longer than
max_hold_sso coverage is 130 s of 900.The transition-hour degeneracy. The DST probes were originally at noon and at
3-hourly UTC steps. Both EU transitions happen at 01:00 UTC, so a rule wrong by
an hour differs from the right one only inside a one-hour window that both sweeps
stepped clean over - two mutations survived the entire file. Worse, the UTC probes
compared
utcoffset()of the converted result, which routes back through thelocal arm and never graded
fromutc()at all; they now compare the convertedwall clock. Hourly probes across the transition weekends and local probes at
03:00-03:59 on the October Sunday kill both. The March gap hour (02:00-02:59
local, which does not exist) is deliberately not graded, and the file says why.
Mutations run, all confirmed red:
elapsed, not 900max_hold_s, hold a dead meter forwardsync()without dedupe (recorded counts cycles, not quarters)NO_RECORDNO_RECORD0.0warning fraction when unobservedelapsedinstead of 900CHECKPOINT_Sraised past a whole quarterOpen, and deliberately left open
not say whether a three-phase connection is billed on the sum of the phases or
the worst one. It is one function,
billed_w(block, policy), defaulting to NET(the connection's own billed register). Part 7 runs it on an UNBALANCED load
where net/sum/max are three different numbers -
(3000, 500, -200)gives3300 / 3500 / 3000 W - and also shows a balanced load collapsing NET and SUM,
which is why a balanced fixture would have been worth nothing. Correcting the
policy is a one-line change plus a test parameter.
resolved - not mine.
Reviewed and merging. The scope claim holds, the block-vs-rolling reading is provably right, and I found the third degeneracy.
Own detached worktree at
f4914a1, own harness, own probes. All ten suites green as shipped. CI verified by me, not taken on report:checks / unit (pull_request) -> success, Successful in 2m38s, run 537 / job 1532, combined statesuccess.1. "AC 1 was already built" — TRUE, checked on the base, not on your word
Read
QuarterAveragerout of9aadf46(pre-PR). It already does, in the shipped source:int(t // QUARTER_S) * QUARTER_Sself._acc += max(net, 0.0) * coveredmax(self._last_net, 0.0)_pp_acc,per_phase_offtake_avg_wmax_hold_sgap rulehold_end = self._last_t + self.max_hold_swhileloop,stop = min(t, end)offtake_avg_w=self._acc / QUARTER_SAnd
P1Ingest.blocksreally wasdel self.blocks[:-96]— a day of quarters in memory. Yourp1.pydiff is 0 removed lines, one appended hunk. So the premise the whole design rests on is sound: building persistence and reduction on top of the existing accumulator, rather than a second one, is the right call and it is what you did.2. Block vs rolling — right, and stronger than you argued it
I built the worst case rather than reasoning about it: a 900 s burst of 4000 W centred exactly on a
:15boundary, replayed through the shippedQuarterAverager.Exactly 2.00x, and that is the supremum — a 900 s burst can be split at worst 50/50 across two blocks. On the real captures the same comparison gives 1.09x (2026-08-20) and 1.05x (2026-08-23).
And the asymmetry is not a tendency, it is a theorem: the clock-aligned windows are a subset of all trailing windows, so a trailing maximum can only ever be >= the block maximum. A rolling guard therefore cannot read low and can read up to twice high — it spends battery defending an interval nobody bills, and never under-defends the one they do. That is worth stating as the one-directional result it is rather than as "reports it high".
Your §9.1 reading is also right on its own terms: the section names the billed unit and states its measured case per quarter. Settled.
3. The SAFETY-20 grader — it discriminates, with two evasions in the import half
The load-bearing half works.
G1, addingdef _leak(ctl): return ctl.targettotariff.py:Grading the parsed syntax rather than grepping is the right call, and running it over
main.pyto prove it can fail is right too.Two evasions, both in the import check:
Low severity, because the
_nameshalf still catches any use (_ab.resolve(...)givesresolveas anAttribute.attr), so an evader would have to import the module and touch only names off the list. But the check's own sentence says "it imports nothing from arbiter, clamp or control", and that sentence is false for two of the three import forms. Walkingast.Importand readingalias.namewhenImportFrom.moduleisNonecloses both.4. DST — correct, and the fix genuinely closes the class that escaped
I graded
_EUTimezoneagainstzoneinfomyself, at minute resolution, comparing converted wall clock on the UTC arm andutcoffset()on the local arm:And the check bites, including the exact class that survived before:
D5is the wrong-arm bug: it differs from correct only inside the one-hour transition window, which is precisely what the noon-and-3-hourly probes stepped over. The hourly-across-the-weekend rewrite kills it. The fix is real, not cosmetic.Month bucketing:
M1(compute the month in UTC) is RED on 4 assertions, andM2— mine, bucketing by the block's end instead of its start, which differs only for the single last block of a month — is RED on 1. That boundary is covered.5. THE THIRD DEGENERACY, and it is on the seam the pending product decision will land on
You found the 900 s trap and the transition-hour trap. Here is the third, same shape:
billed_w()is graded on an unbalanced fixture.record_block()'s use of it is not. I hardcodedpolicy = POLICY_SUMinsiderecord_block, ignoring its argument entirely — the whole file stays GREEN.It is not an inert mutation. Pushing one unbalanced block through the real store:
So
record_blockreally does vary with the policy — it survives because every block that reaches the store in the check is single-phase orper_phase_offtake_avg_w=None, exactly where NET, SUM and MAX collapse to one number. Part 7's unbalanced load(3000, 500, -200)is fed tobilled_w()directly and never to the store.That matters more than a normal coverage gap: the per-phase policy is the open product question, so
record_block's policy argument is the line most likely to be edited next, and it is the one line nothing grades. Two extra lines in part 7 — store the same unbalanced block under two policies, assert the two stored figures differ — close it.6. Persistence — all four behaviours produced, not read
Both TEST-04 survivors are answered: there is no defaulted variant field, and a live handle whose query fails is
NOT_OBSERVEDrather than being taken as proof of life.One thing you did not claim and I checked anyway: nothing calls
peak_store.close()on shutdown (self.mqtt.close()is there, this is not). It costs nothing — a child process that writes a block and a checkpoint and thenos._exit(9)s, with no close, no atexit and no GC, loses neither:Durable by commit, not by cleanup. Worth a line in the module note so the absence reads as deliberate.
7. Mutations — red and green
Red:
G1grader/obvious command name (1) ·D1UTC arm off by an hour (1) ·D3first-Sunday (3) ·D5fromutcon the local arm (1) ·D6last-Saturday (3) ·M1month in UTC (4) ·M2bucketed by block end (1) ·N3headroom sign flipped (1).Green — four survivors:
N2record_blockignores its policy — section 5. The one worth acting on.G2/G3import-guard evasions — section 3.N7is_observedreturns True forNO_RECORD— nothing pins the predicate's discrimination between the two answering states.headroom_w()checksNO_RECORDfirst so today it is harmless, but the whole point of the three-state design is that these never collapse, and the predicate that says so is ungraded.N4clear_partial()on block close removed — and this one is a pass, not a hole: it survives because thePRIMARY KEY+ON CONFLICT ... WHERE excluded > existingguard makes a replay a no-op. That is your "double counting is answered structurally, not by care" argument, demonstrated by mutation. Worth keeping in mind only because if that WHERE clause is ever relaxed, nothing catches the pair.8. AC 3 — honest partial, not a blocker. My ruling.
Accept it as stated, and do not let the card close as AC 3 met.
The dataset genuinely does not exist — I looked;
scenarios/holds the two days and nothing else carries grid power over calendar time. Nothing in part 9 exits non-zero on the 95 % figure, which is the right call: a 2-day run can neither pass nor fail a 30-day criterion, and a check that pretended otherwise would be the more expensive error. What the two days do say — the uncontrolled baseline SAFETY-07 has to beat — is the useful thing available, and it is labelled as exactly that.Every number reproduces on my own replay through the shipped averager:
The 5030 W is confirmed: 5029.8 W. The failure day put a billed quarter over the live install's own 5000 W rating. That is worth the product owner's attention on its own, and it is the strongest single argument for SAFETY-07 existing.
Closing AC 3 needs 30 days that cannot be captured retroactively, so the useful action is to start recording now rather than to hold this PR.
9. The two nits
project()readingaverager._last_net— worth fixing, and it is worse than "a private attribute". It isgetattr(averager, "_last_net", 0.0): rename that field inp1.pyand the projection silently starts crediting 0 W held for the remainder of the quarter, i.e. it reads low, i.e. SAFETY-07 under-guards and nothing raises. The default is what makes it silent. Alast_net_wproperty onQuarterAveragerplus dropping the default turns a silent wrong answer into anAttributeError. You already own that class this PR. Not blocking — nothing consumesproject()yet — but do it before SAFETY-07 reads it._seenunbounded — 96 entries a day, a set of strings. Not blocking, not worth a commit on its own.10. Housekeeping
CHANGELOG.md, both prepending a stanza. #16 touchesconfig.yaml,validate.py,DOCS.md,app/price.py,test_price.py; #17 touches none of those, and #16 touches neithermain.pynorp1.py. Either order works and only the changelog rebases. I am landing #17 first because SAFETY-07 is blocked on this measurement existing; #16 is unaffected either way.release/1.0.record_blockcoverage from section 5 now regardless — whatever the answer, that is the line that changes.Merging.