TARIFF-01: measure the month's billed capacity-tariff peak, and make it survive a restart #17

Merged
admin merged 1 commits from TARIFF-01 into release/1.0 2026-08-30 19:33:58 +02:00
Owner

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 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_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.
## 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.
admin added 1 commit 2026-08-30 19:16:34 +02:00
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
Author
Owner

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.

## 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.0 2026-08-30 19:33:58 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#17