Compare commits

...
13 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 c2ed509565 Merge release/1.0 (TARIFF-03) into SAFETY-07
checks / unit (pull_request) Successful in 3m12s
The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md
auto-merged, so the overlap claim on #18 still holds - config.yaml and
validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03
widened both.

Full suite green on the merge result, test_price.py and test_static_tariff.py
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:43:35 +02:00
glenn schrooyenandClaude Opus 5 2d570d84ce SAFETY-07 review: defend the three wiring mutations that survived
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.

B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.

WHAT CHANGED

The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.

test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.

    SHIPPED   suspended=True    loop -> 1498 W, limited by peak    +1497.8 W
    B1        suspended=False   CONTRADICTION between limits           0.0 W
    B2        suspended=False   CONTRADICTION between limits           0.0 W
    B2b (forecast term dropped instead) - caught by its own check

B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:

  * the value main.py actually hands decide() is recorded off the real call, with
    the measured battery at 0 W and the command standing at +3000 W;
  * the one-cycle difference is asserted to be exactly the standing command - the
    whole defect in one line, watts that were asked for and never arrived counted
    as if they had;
  * both forms are then run forward against the REAL controller's own projection
    with an inverter that never follows:

        intent-sized floor    4482 5000 5000 5000 ...  SATURATED at cycle 2
        delivered-power floor 1482 1482 1482 1482 ...  stationary

  * and the call site is graded on parsed syntax, so a rename cannot quietly
    reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.

SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.

RED AND GREEN

Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:40:39 +02:00
admin cf5094c608 TARIFF-03: the static day/night profile as a first-class product and the 11.4 fallback (#19)
app/static_tariff.py: StaticProfile derives STATIC vs FIXED from its own prices, one generator serves both the configured product and the degradation fallback differing only in the provisional flag. TariffPlan.arbitrage() is a refusal type behind a required keyword-only peak_guard_active, and a flat tariff is refused by arithmetic rather than by matching PriceProduct.FIXED. main.py touched only in read_inputs() else-branch, one field, one status row - nothing in cycle(), the arbiter or the peak path; an empty profile is bit-for-bit the old behaviour. Rule 7 tripwire re-aimed rather than silenced.
2026-08-30 23:24:53 +02:00
glenn schrooyenandClaude Opus 5 9f8b721a4d Merge release/1.0 (TARIFF-02) into SAFETY-07
checks / unit (pull_request) Successful in 3m9s
Conflict was the one anticipated: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. Nothing else of
TARIFF-02's surface (app/price.py, validate.py rule 7, config.yaml, DOCS.md)
overlaps this card - config.yaml and validate.py are untouched here, so the
CFG-03 check still reads a config.yaml that only TARIFF-02 has widened.

Full suite green on the merge result, test_price.py included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:13:29 +02:00
glenn schrooyenandClaude Opus 5 303525cfc5 TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.

Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.

"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.

Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.

A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.

validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.

CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.

Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.

All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:07:53 +02:00
glenn schrooyenandClaude Opus 5 616b62d3e9 SAFETY-07: cap the loop before it sets the month's billed capacity peak
checks / unit (pull_request) Successful in 3m7s
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.

THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.

    excess = 900 x (projected - ceiling) / seconds remaining
    floor  = MEASURED battery power + excess

The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.

IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.

BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.

THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.

TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:

  * A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
    limit at or above the winner's priority and takes the most restrictive, so
    the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
    resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
    it should shave. Fixed by suppression, not priority: main.py drops the
    maintenance shaping while the guard is engaged, which is what that branch
    already did for the optional peak_forecast_entity - a key empty on a default
    install, so until now the suspension could not fire on the site that needed
    it.
  * Thresholding `excess > 0` released the claim the instant it began working,
    producing seventeen engage/release cycles in one 900 s quarter and leaving
    that quarter 49 W over the limit it had spent fifteen minutes holding. That
    is 10.2's oscillation. The threshold is now the floor itself, which is the
    same arithmetic read as a level rather than as a switch.

PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.

NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.

test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:05:05 +02:00
admin 91ea676af4 TARIFF-02: price provider plugins + the canonical all-in price format (#16)
Five providers behind a registry, canonical all-in {start, eur_per_kwh, provisional}. Plugins return RawPoints and declare capabilities().sources; assemble() adds the complement once. register() refuses at import any plugin whose class code mentions a framework key, PRICE_OPTION_DEFAULTS, price_option or _o - closing the three routes review used to double-count. PriceRead.series/no_data as an absence type; all-zero is a series, no_data forbids arbitrage. Additive only: main.py untouched, zero deletions.
2026-08-30 22:35:49 +02:00
adminandClaude Opus 5 862f0fa574 Merge release/1.0 (TARIFF-01) into TARIFF-02
checks / unit (pull_request) Successful in 2m39s
TARIFF-01 (#17) landed on release/1.0 first, so this branch was two
commits behind and Gitea reported the PR unmergeable. Integrating rather
than rebasing: the two reviewed commits stay addressable, and the branch
is protected against force-push anyway.

ONE CONFLICT, AND IT IS THE BORING HALF OF THE ONE REVIEW PREDICTED.
Review of #16 expected collisions in four places - the tail of
`options:`, the tail of `schema:`, the rule list inside
validate_startup_config(), and the tail of validate.py. None of them
happened: TARIFF-01 touched neither config.yaml nor validate.py. It
added app/tariff.py, test_tariff.py, two P1 scenario captures, and edits
to main.py and p1.py, none of which this branch goes near.

The only conflict was CHANGELOG.md, where both branches prepended a
block under the same `## Unreleased` heading. Both blocks kept, TARIFF-02
above TARIFF-01 since it lands second. Nothing was dropped or reworded on
either side, and the resolution adds no line of its own beyond the blank
one between them.

Checked rather than assumed after the merge:
  - all ELEVEN test files pass, including TARIFF-01's own test_tariff.py
    (which this branch had never run) and the 93 s test_loop.py;
  - the 24-mutation battery still has 0 survivors;
  - no semantic overlap in config: TARIFF-01's peak tracker reads the
    peak_cap_w / peak_forecast_entity options that already existed, so
    the price options added here neither shadow nor duplicate anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:42:16 +02:00
adminandClaude Opus 5 e102cde841 TARIFF-02 review: close the three other doors to the markup, and stop overclaiming
checks / unit (pull_request) Successful in 2m34s
Review of PR #16 wrote a double-counting plugin four ways. PluginOptions
blocked one. The one that decided it touched nothing but
PRICE_OPTION_DEFAULTS - a public dict in the module every plugin must
already import for register() - so "the number is not in scope" was
simply false, and an absolute guarantee a reviewer trusts is worse than
a stated limit, because they stop checking.

register() now refuses, AT IMPORT, any plugin whose own code mentions a
framework-owned name: FRAMEWORK_KEYS plus PRICE_OPTION_DEFAULTS,
price_option and _o. code_leaks() parses the class and drops every
docstring node before searching, because every plugin is REQUIRED to
name its sourced components in prose - engie_dynamic says "markup" in
its docstring and a raw text scan false-positives on the documentation
the same file mandates. A check that fires on required documentation is
a check somebody deletes. test_price.py asserts that a raw scan really
would have false-positived, so the strip is proven load-bearing rather
than asserted.

It fails OPEN if inspect.getsource() cannot find the .py, which the
add-on image always can. Refusing to start a battery controller because
a static analysis could not run would trade a real outage for a
hypothetical accounting error - so instead the checks assert source was
found for all five shipped plugins, and a build that lost it is a red
check rather than a quietly disarmed guard.

All three reachable attacks are written out as plugins in the suite and
each must be refused at registration. The cost is measured, not
asserted: honest wholesale 0.188, every attack 0.219 - one markup high,
every hour, invisible in every log this add-on writes.

The guarantee is now stated at its true size everywhere it appears: the
framework adds the complement exactly once, GIVEN A CORRECT sources
DECLARATION. A plugin that declares {ENERGY} while its feed is all-in
still doubles while touching no framework name, and no code can catch
that - the declaration is an unverifiable claim about somebody else's
tariff. Measured in the suite (0.276) so the limit is a number rather
than a caveat. The docstring/declaration check verifies that a plugin's
two claims AGREE, not that either is true; that now says so.

The three surviving mutations:

M3 - _NoData's reason taking a default. This is the identical survivor
left open on RIG-01's _Unread.cause: the shape was borrowed from
sim/riglock.py including its blind spot, which is what borrowing a shape
does if nobody re-runs the mutations against the copy. Closed by
SIGNATURE - no constructor or handler of an outcome type may carry a
default - so the next one is red without anyone writing a new case.

M5 - the ENERGY-required guard was untested. Its consequence is that
adds becomes {ENERGY, TAXES} and the emitted price silently omits the
energy it is pricing.

M7 - deleting the TLS refusal went green AND the check named for it
still passed, because the old parser stripped a fixed len("https://")
and mangled http://api.energy-charts.info/p into the host
pi.energy-charts.info, which then failed the ALLOW-LIST. An assertion
that does not test what it names, inside a security check. Fixed on both
sides: the scheme is parsed as a scheme, so an http URL now resolves to
its real and declared host and only the TLS rule can refuse it; and the
check asserts the REASON. Fixing it surfaced M7b - the no-scheme guard
was survivable the same way - found by mutating my own fix, and closed
the same way.

Also carried forward: rule 7's refusal is correct forever but its stated
reason ("the provider decides nothing") expires the moment TARIFF-03 or
OPT-04 lands. test_price.py section 12 now greps app/ by AST for a
load_prices consumer and goes red when one appears, telling whoever
lands it to update the message in the same commit. The first draft of
that tripwire used a substring and matched the comment in validate.py
explaining the tripwire - the same false-positive class as the plugin
docstrings, hit twice in one ticket, and fixed the same way.

Documented rather than coded around: vars(read).get("points", []) and
bool(read) both still get past the absence type, and neither is worth
code - defining __bool__ would turn a line that reads like a guard into
one that IS a guard by accident, which is how .get(k, default) got here
in the first place.

Stated plainly in DOCS/CHANGELOG/PR: every fixture is synthetic, so the
five feed URLs and JSON shapes are verified against a belief about what
those suppliers publish rather than against the feeds. A wrong shape
degrades to no-price-data, not to a wrong price.

Review's own DST verification recorded: 149,040 hourly instants,
2020-2036, zero mismatches against zoneinfo. The residual is political,
not arithmetic, and the docstring now says so.

24 mutations driven through the suite, 0 survivors. Whole add-on suite
green. No deleted lines outside this ticket's own files; main.py still
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:35:36 +02:00
admin 0dbd485b5a TARIFF-01: measure the month's billed capacity peak, and make it survive a restart (#17)
The instrument SAFETY-07 will read: the highest clock-aligned 15-minute average offtake of the calendar month, persisted in /data/tariff.db across two retention tiers, with a monthly peak that recompute_month() proves has not diverged from the 15-minute tier. Files no claim, applies no clamp, commands nothing.

AC 1 was already built and unwired - p1.QuarterAverager on the base already does clock-aligned 900 s blocks, time-weighted over irregular spacing, offtake-only, per-phase, with the max_hold_s gap rule; verified on 9aadf46 rather than taken on the PR's word, and the p1.py diff removes zero lines. So this is persistence and reduction rather than a second source of truth for the quantity the money depends on.

Block rather than rolling, and the reading is provable: a 900 s burst centred on a :15 boundary bills at 2000 W and reads 4000 W through a trailing window - exactly 2.00x, the supremum - and since the aligned windows are a subset of all trailing windows, a rolling figure can only ever read HIGH, never low. On the real captures, 1.09x and 1.05x.

Reviewed independently: the encoded EU DST rule graded against zoneinfo at minute resolution over eight transitions, all of 2026 hourly and every month boundary - 0 mismatches - and the wrong-arm fromutc() mutation that differs only inside the one-hour transition window now goes red, which is the class that escaped the first draft. All four persistence behaviours produced rather than read, including a hard os._exit with no close() losing neither a banked quarter nor the open checkpoint. The SAFETY-20 syntax grader discriminates on names; two evasions remain in its import half.

AC 3 is NOT met and the check says so: no 30-day dataset exists. The two days that do exist replay to 189/190 quarters = 99.5 % under the 3500 W soft limit, uncontrolled - the baseline SAFETY-07 must improve on - and 2026-08-20's peak reproduces independently at 5029.8 W, a billed quarter over the live install's own 5000 W rating. Do not close the card as AC 3 met; start the 30-day capture.

Four reviewer mutations survive, recorded on the PR. The one to act on: record_block() ignoring its policy argument stays green, because every block reaching the store in the check is single-phase where NET, SUM and MAX collapse - the third instance of the degeneracy trap this file already documents twice, sitting on the exact line the pending per-phase product decision will change.

No conflict with TARIFF-02 (#16) beyond CHANGELOG.md, verified from both sides.
2026-08-30 19:33:57 +02:00
glenn schrooyenandClaude Opus 5 f4914a174a TARIFF-01: measure the month's billed peak, and make it survive a restart
checks / unit (pull_request) Successful in 2m38s
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
2026-08-30 19:13:58 +02:00
adminandClaude Opus 5 852fc7ced6 TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s
Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, manual_csv - all emitting exactly
{"prices": [{"start", "eur_per_kwh", "provisional"}]}, tz-aware at the
Brussels offset, EUR/kWh, all-in. Nothing consumes it yet; the seam
lands first, per the PM's ordering.

It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.

Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:

Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.

The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.

"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. A fetch returns PriceRead.series(...)
or PriceRead.no_data(reason), and only the first carries fields. An
empty series is not constructible; no_data has no .points, no .at(), and
.get("points", []) raises rather than returning the default. A day of
free electricity and a day with no prices are different instructions -
arbitrage is pointless under one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.

Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.

validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.

CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.

test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:05:12 +02:00
admin 9aadf469bc TEST-06: pin the cycle() ordering, the NOT COVERED string and the shipped meter_freeze_s (#14)
Closes the three mutations that survived SAFETY-18 suite green. Ordering pinned by consequence (N8 red above the assignment, N3 green below it); NOT COVERED asserted in both directions; shipped meter_freeze_s and its schema bounds pinned. notes_of()/all_notes_of() split so this guard and the whole notification surface are separately nameable, with the three surface-wide claims decided per scenario after SAFETY-03. app/ change is comment-only (AST-identical).
2026-08-30 18:39:07 +02:00
16 changed files with 8809 additions and 14 deletions
+293
View File
@@ -2,6 +2,299 @@
## Unreleased
**The grid-following loop is now capped against the month's billed capacity peak
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
consumer it was built for. Every cycle the guard projects the open quarter,
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
on charging and pre-emptive discharge — and it cannot cap discharge or export,
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
the floor is the MEASURED battery power plus that: a floor built on the standing
command winds itself to the rail whenever the inverter fails to follow, and one
built on delivered power cannot.
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
healthy reference day it engages on zero of 95 quarters; the action is
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
3485 W**, under the soft limit and without saturating. Quarters under the limit
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
still TARIFF-01's open item and this does not claim it.
**Blindness forbids importing, and it is checked before the projection is
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
and a frozen meter reading export projects 0 W at full coverage. All three become
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
on this cycle's standing command, not a second copy of the conjunction.
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
be attached to it: today a `peak_w is not None` test behaves identically, and
depending on that coincidence is how a future last-known-peak field would silently
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
consumed — `cap − peak` turns a zero peak into FULL freedom.
**Two defects found building it, both in the interaction rather than the
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
every limit at or above the winner's priority and takes the most restrictive — so
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
should shave. Fixed by suppression rather than priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch already
did for the optional `peak_forecast_entity` — a key that is empty on a default
install, so until now the suspension could not fire on the site that needed it.
And thresholding `excess > 0` released the claim the instant it started working,
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
itself, which is the same arithmetic read as a level rather than as a switch.
**`PeakTracker.project()` no longer reads a private name with a default.**
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
a rename credits 0 W held, the projection reads low, and the money guard does not
engage. `QuarterAverager.held_net_w` is now a documented public property, read
without a default, and `Projection` carries it. TEST-13 item 4.
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
and the achievable discharge comes from `max_w` and the device rating.
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
no telegrams, so the guard does not run at all and the status page says NOT
COVERED — running it there would have blinded it permanently and stopped every
legacy install from charging, which is what the first cut did until
`test_clamp.py`'s export case caught it.
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff — or a single
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
so nothing downstream learns that a tariff can be static.
**It is not a sixth provider plugin, and the reason is mechanical.**
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
products with two different required behaviours, and which one a site is on is a
property of the operator's data, not of any class. `StaticProfile.product`
derives it from the number of distinct prices instead. The second reason: the
plugin framework exists to stop a *fetched* figure being marked up twice, and a
number the operator typed as all-in has no publisher to disagree with.
**So the profile sits where §11.4 already says it does — under the providers, as
the fallback.** The configured-product path and the feed-is-down path are the
same generator differing in one flag: `provisional`. A static-tariff site runs
that path every day, so the degradation path for a dynamic site cannot rot
unused.
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
spread and the cheapest and dearest hours, and the *refused* arm carries a
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
answering 0.0. An optimiser cannot compute a trade from a refusal because it
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
holding, there is no price data, the plan is provisional, or **the series is
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
arithmetic rather than by matching on an enum, and a dynamic feed that happens
to print one price all day is refused for the same true reason.
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
spread is not obtainable without first answering whether §10.1's priority 2 is
holding. §9.1's capacity tariff is a network cost charged whatever the energy
product is.
**Time-window shifting reaches the one shiftable load that exists.** main.py's
cheap-window flag — previously fed only by the `price_now_entity` /
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
gained a **Tariff** row naming the window, the price, when it next changes and
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
would top up from the grid at any hour, which is the opposite of
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
cheap or dear depending on when you asked.
**A profile must tile every day type exactly once, checked at startup.** A gap
is refused with the uncovered range named rather than filled in — an invented
price is one an optimiser would trade on — and an overlap is refused because
otherwise one of two prices wins silently on list order. The trap this catches
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
a window applies on the day the clock reads.
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
wording in the same commit when it went red. It went red naming
`static_tariff.py`. The message now says what is true instead — the series *is*
consumed, but main.py acts on the static side of it and not on a fetched one —
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
`static_profile` alongside the Home Assistant price pair is the same
silent-loser conflict, and a live one.
**No new place for an option default (CFG-03 is not widened).**
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
than to a defaults dict in the new module, which has none. main.py grew no
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
here so a reviewer does not have to infer it.
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
night-priced, which is the weekend row proving itself on real data. §17 names
six Belgian scenarios and a static-profile case is not among them; this is the
seventh, and it is two captured days rather than a season — the file says so
rather than implying an annual figure.
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
for the tariff alone on such a site. That is a product finding, not a defect.
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
consumes it yet**: this is the seam, landed first, so that the arbitrage work
that follows never learns a supplier's name.
**Double-counting the markup is refused rather than discouraged, given a
correct declaration.** A plugin does not return a price; it returns the figure
its source published and declares which of {energy, markup, taxes} that figure
contains. The framework adds the complement, once. Engie and TotalEnergies
publish a retail figure with their margin already inside; adding the configured
3 c/kWh on top would make every future arbitrage decision wrong by that amount
with nothing reporting it.
Two mechanisms, because one was not enough. `PluginOptions` raises on the
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
public dict in the module every plugin already imports. So `register()` now also
**refuses at import any plugin whose own code mentions a framework key**
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
EUR/kWh; every attack landed 0.219.
**The guarantee's precise shape**, stated because an overclaim is worse than no
claim: the framework adds the complement exactly once *given a correct `sources`
declaration*. A plugin that declares `{energy}` while its feed is really all-in
still comes out one markup high while touching no framework name, and no code
can catch that - the declaration is the plugin's unverifiable claim about
somebody else's tariff. What is mechanised is reachability; what stays human is
truthfulness.
**The product type is declared, not inferred from the plugin's name.**
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
thresholds; that reaches a consumer through `capabilities().product` and
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
mechanically that no plugin id appears anywhere in the framework's own code,
and drives a sixth provider defined inside the test file to the correct regime
with `price.py` untouched.
**"No price data" is a type, not an empty list.** Following the same rework
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
`.get("points", [])` raises rather than returning the default. **A day of free
electricity and a day with no prices are different instructions** - arbitrage is
pointless under the first and forbidden under the second - and an implementation
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
failure returns `no_data`; nothing raises into the control tick.
**What is not verified, and cannot be by an offline suite:** every fixture is
synthetic, so the five feed URLs and JSON shapes are checked against a belief
about what those suppliers publish, not against the feeds. The parse layer is
exercised; the contract with each supplier is not. Expect the first live fetch
of each to need a shape fix, and note that a wrong shape degrades to
no-price-data rather than to a wrong price.
**`manual_csv` is the documented offline fallback** and needs no network, no
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
column must carry a UTC offset - a bare local timestamp is refused, because one
hour every October names two moments.
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
than a second series, and negative values are accepted. Consumption plugins
cannot reach it.
**`price_provider` may not be combined with `price_now_entity` /
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
Assistant sensors are the existing delegation of pricing to HA and still work
alone; with both configured they would silently keep deciding while the status
page named a supplier that decided nothing.
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
Brussels offsets are computed from the EU rule and every instant is carried as
UTC and rendered at the offset that applies to it. The checks drive both 2026
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
fixed 24-slot implementation and a correct one agree.
**The capacity-tariff peak is now measured, banked and survives a restart
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
blocks, time-weighted over irregular telegram spacing, offtake-only and
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
the indefinite 15-min tier, one row per calendar month in the permanent peak
tier, and no `DELETE` against either anywhere in the module.
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
continuously updated, not a trailing-15-minutes window - a trailing window
reports a peak the meter never bills, and reports it HIGH, because a burst
straddling :15 fills it completely while contributing to two block averages at
partial weight.
**A restart mid-block resumes it, and a restart that outlives the block banks
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
checkpoint in the same breath as a block close. The held sample keeps its
ORIGINAL timestamp across the restart, so the outage is integrated under the
same `max_hold_s` rule a running process would apply and uncovered seconds drag
the closed block DOWN rather than being held forward. `quarter_start` is the
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
double-count and a partial re-run cannot lower a quarter already measured.
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
`riglock.holder()` fix applied to something that spends money. A peak read as
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
moment its own storage breaks, so there is no accessor that defaults it: a
caller who forgets gets a `TypeError`, and the status page prints a different
row for "no quarter yet" than for "the database did not answer".
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
31 August bills in September. Block alignment still needs no timezone.
**No new option, and no second peak entity.** The soft limit is the existing
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
**It measures and nothing else.** No claim, no clamp, no command; graded on the
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
ever sees `P1Sample`, the meter's own reading, never a setpoint.
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
the percentage under the shipped limit next to the sample size in days, and
asserts nothing about the 95 % figure.
**A computed setpoint over the rating now alarms instead of being clipped in
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
+230 -7
View File
@@ -439,6 +439,11 @@ maintenance schedule.
#### Capacity-tariff exposure of the grid-following loop
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
capped — see "The capacity-peak guard" below — and what follows is kept because it
is still the clearest statement of WHAT is being guarded and why the number is
worth what it is.**
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
grid-following loop is bounded by `max_w` and by the device rating, and by
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
@@ -463,16 +468,72 @@ What exists today, and what does not:
|---|---|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
| Maintenance charging capped under `peak_cap_w` | ✅ |
| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 |
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
| Frozen meter *stopped* | ❌ — the alarm does not command anything |
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
So the frozen-meter alarm below **narrows the window; it does not close it.** On
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
today is operational: set `max_w` no higher than the loop actually needs (the
tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit
the e-stop, and watch for the alarm.
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
quarter-hour average to guard, and the status page says **NOT COVERED**. There
the mitigation is still operational: set `max_w` no higher than the loop actually
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
there), fit the e-stop, and watch for the alarm.
### The capacity-peak guard
**What it does.** Every cycle the add-on projects the open quarter to its
end-of-interval average — from P1 telegrams only, never from the command — and
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
a hard clamp on charging while the bound sits above the standing charge, and
pre-emptive discharge while it sits above zero. It can never cap discharge or
export, because a floor cannot.
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
the floor is the **measured** battery power plus that. The 900/remaining factor
is the design rather than an accident: it is 1.0 at the top of the quarter and
grows, so the guard is gentlest when its projection rests on the least evidence
and becomes aggressive only once the quarter is nearly measured.
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
already stands higher — once a month's bill is set at 5013 W, holding later
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
ceiling at the cap, so a broken instrument makes the guard stricter, never looser.
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
The action is level-triggered and unlatched — re-derived from a fresh projection
every cycle, released the instant the projection drops back — so a false
engagement costs one cycle of discharge rather than a parked battery. And it is a
LIMIT, so every stop in the watchdog table still outranks it.
**Replayed on 2026-08-20**, the day the vendor controller failed: the billed
quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the
guard brings it to **3485 W** — under the soft limit, without ever asking for more
than the inverter can give. Across both committed days the quarters under the
limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3;
it is the comparison two days can support.
**What it alarms on is saturation, not engagement.** An engagement is an ordinary
control action and goes to the action log and the status page. A *saturated*
guard — asking for everything the inverter has while the quarter is still
projected over — raises one notification (`goodwe_peak_saturated`), because that
is the one state where the money will be spent and shedding load is the only
thing left that changes the bill.
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
coverage threshold is `meter_max_age_s`, the freeze predicate is
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
rating — every one of them a key that already exists.
### The winding-loop alarm (the output clamp)
@@ -581,6 +642,168 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### Energy price providers
> **No *fetched* price moves energy in 0.5.x.** This release ships the
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
> and the config that selects one. Turning a provider on fetches, validates and
> publishes prices and changes no charging decision; the arbitrage strategy that
> would use it is a later ticket.
>
> The one exception is `static_profile` below. A static day/night tariff needs
> no network, so its cheap window is computed on every cycle and does reach the
> maintenance cycle's grid top-up.
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the five plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
**Which plugin sources what.** This is the one thing to get right, because
adding your markup to a price that already contains it makes every future
arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
| `manual_csv` | day-ahead | everything, all-in | nothing |
You cannot get this wrong by editing the markup: each plugin *declares* what its
feed contains and the add-on adds only the rest. Set a markup under a plugin
that already carries one and it is **ignored, with a line in the log saying so**
— never applied twice.
That holds **given a correct declaration**, and it is worth knowing where the
line is. What the add-on enforces mechanically is that a plugin cannot obtain
the markup or apply it: the numbers are not reachable from plugin code, and a
plugin whose code mentions them fails to load at all. What it cannot check is
whether a plugin described its supplier's feed truthfully — that is a claim
about somebody else's tariff, and no code can verify it. If you write a plugin,
the `sources` declaration is the part a reviewer has to read.
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
flagged as high-volatility, which widens arbitrage thresholds once the
optimiser lands. That comes from the plugin's own declaration, not from its
name.
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
account and no HTTP stack at all, and it is the supported way to run this
add-on price-aware on a site with no outbound connectivity. Write a file and
point `price_csv_path` at it:
```csv
start,eur_per_kwh,provisional
2026-01-15T00:00:00+01:00,0.1834,false
2026-01-15T01:00:00+01:00,-0.0210,true
2026-01-15T02:00:00+01:00,0.3115,false
```
- `start` **must carry its UTC offset** (`+01:00` in winter, `+02:00` in
summer). A bare `2026-01-15 00:00` is refused, because for one hour every
October a local wall clock names two different moments.
- `eur_per_kwh` is **all-in** — energy *plus* markup *plus* taxes. This plugin
has no way to know what your numbers already include, so it assumes
everything and adds nothing.
- Negative prices are valid and are kept. They are the best charging hours of
the year.
- `provisional` is optional; omit the column and every hour is treated as
settled.
**When there are no prices at all** — provider off, feed unreachable, CSV
missing — the add-on does not guess. It reports *no price data*, which is a
distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
publishes a dynamic injection tariff; negative values are accepted.
**The old Home Assistant price sensors.** `price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
uses the result as a cheap-window hint for the maintenance charge. They are
**not compatible with `price_provider`, and not with `static_profile` either**
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
It is one option, holding a JSON object, on one line:
```json
{"periods": [
{"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
{"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
{"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
```
A **fully fixed** tariff is one period covering the whole week:
```json
{"periods": [
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
```
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
`price_taxes_eur_kwh` are **not** added to it.
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
includes 07:00 and excludes 22:00. A window may cross midnight.
- The clock changes twice a year and the add-on handles it. It generates prices
against absolute instants, so a spring-forward day is 23 hours and an
autumn-back day is 25.
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
> Saturday 03:00 is Saturday's. That is why the example above has a third row
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
> uncovered hours.
**Every day type must be covered exactly once.** The periods have to tile all
seven days with no gap and no overlap, and the add-on checks that at startup. A
gap is refused rather than filled in, because a made-up price is one the
optimiser would trade on; an overlap is refused because otherwise one of the two
prices silently wins on the order you typed them in.
**What it changes today.** The add-on works out which window you are in and
whether that window is *cheap* — strictly below the tariff's seven-day average.
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
cheap-window grid top-up uses it, which is exactly the "shift load into the
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
on a price yet.
Under a **fully fixed** tariff every hour equals the average, so there is *no*
cheap window by construction and the add-on runs self-consumption only — which
is the correct behaviour for that product, not a limitation. Peak shaving, the
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
unaffected: the capacity tariff is a network cost charged whatever your energy
contract is.
**Recommended even on a dynamic contract.** When a price feed is unreachable the
add-on falls back to your static profile, marks the plan provisional and
disables arbitrage. With `static_profile` empty there is nothing to fall back
to, and the site simply has no tariff until the feed returns. It is not required
— the add-on warns rather than refusing — but filling it in is what makes that
fallback work.
### Site
| option | meaning |
+306 -7
View File
@@ -33,18 +33,23 @@ from datetime import datetime, timezone
import aiohttp
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
is_commandable,
)
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
)
from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .static_tariff import static_status
from .tariff import PeakStore, PeakTracker
from .validate import ConfigRefused, refuse, validate_startup_config
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
from . import web
@@ -63,7 +68,8 @@ def load_options() -> dict:
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop):
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
self.o = opts
self.hass = hass
self.store = store
@@ -163,6 +169,45 @@ class Controller:
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
max_age_s=float(opts.get("meter_max_age_s", 30)))
self.p1_enabled = is_enabled(opts)
# TARIFF-01. The capacity-tariff peak: measured from the meter, banked
# in SQLite, restored across a restart. It is an INSTRUMENT - it files no
# claim and clamps nothing. SAFETY-07 is what will read it.
#
# ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped,
# already validated, already read by control.peak_at_risk(). CFG-03
# tracks three places option defaults live; a fourth, for a limit that
# already exists, is the mistake the clamp watchdog declined to make ~30
# lines up. config.yaml and validate.py are untouched by this ticket.
#
# ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing
# HA-side delegation and it stays the input to maintenance_charge_floor():
# repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY
# during a maintenance charge, which belongs to SAFETY-07 with its own
# acceptance, not to a measurement ticket. What this adds is the MEASURED
# MONTH PEAK - a different quantity from a forecast entity, published
# nowhere as a duplicate of it.
self.peak_store = PeakStore(peak_db_path)
self.peak = PeakTracker(self.peak_store,
cap_w=float(opts.get("peak_cap_w", 3500)))
self.peak.restore(self.p1.averager)
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
# design (peakguard.py holds no history between calls), so the only
# state here is what an EDGE needs: what the last cycle concluded, so
# the action log and the notification fire on transitions rather than
# once per cycle for a quarter of an hour.
#
# ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90
# lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already
# shipped and already validated, and every other term is read off
# something the arbiter or the averager already owns. CFG-03 still
# tracks three places, not four.
self.peak_guard = None
self._peak_engaged = False
self._peak_saturated = False
self._maint_shape_suspended = False
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -183,6 +228,10 @@ class Controller:
self.grid = self.soc = self.batt = None
self.peak_fc = None
self.cheap = False
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
self.last_write = None
self.last_write_at = None
self.last_write_ok = None
@@ -236,9 +285,31 @@ class Controller:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self.tariff_words = "Home Assistant price entities"
else:
# Fixed-tariff site: never force a paid grid top-up, wait for sun.
self.cheap = False
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
# The add-on has exactly one shiftable load in 0.5.x - the
# maintenance cycle's cheap-window grid top-up, ~15 lines below in
# cycle() - so this is where the window reaches something that moves
# energy. With `static_profile` empty this returns False and the
# behaviour is bit-for-bit what it has always been: "fixed-tariff
# site, never force a paid grid top-up, wait for sun".
#
# ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed
# resolver (static_tariff.resolve_tariff) can perform an HTTP fetch,
# and this coroutine is on the path that feeds the ESP32 heartbeat -
# a supplier's TLS timeout here would be a supplier able to push the
# add-on towards layer 1's 30 s veto. Generation from a static
# profile is pure arithmetic, so §2.3's "regenerate on the tick"
# applies and there is nothing to block on. Putting a fetched series
# on this path needs a BACKGROUND refresh and belongs with OPT-04.
#
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
# alongside the two Home Assistant entities, because this `if` picks
# one and the loser is silent.
self.cheap, self.tariff_words = static_status(
o, datetime.now(timezone.utc))
if self.inputs_ok:
if self.inputs_bad_since is not None:
@@ -372,6 +443,77 @@ class Controller:
self.log_event("inputs missing - commanding 0 W")
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
# --- SAFETY-07: the quarter-peak projection guard --------------------
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
# What is decided HERE and nowhere else is what the guard is allowed to
# see and what it is allowed to ask for:
#
# ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON
# `self.freeze_wd.active`. The watchdog object is advanced at the END of
# cycle() (check_meter_frozen), so reading its level here would grade
# this quarter against the PREVIOUS cycle's conjunction - one cycle of
# lag, in a guard whose entire job is to act inside a 900 s quarter.
# `standing` is the value SAFETY-02 already computed above for exactly
# this meaning: the command on the wire that this meter reading is a
# response to. The predicate itself is freeze.py's shipped
# `looks_frozen()`, not a copy of it - see loop_is_following() on what a
# transcribed safety predicate costs.
#
# ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE
# SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and
# resolves to 0 W - the battery parked, in the one minute of the month it
# should be discharging hardest. maintenance_charge_w had to learn the
# same lesson (config.yaml, validate.py rule 6); this claim is built so
# the config cannot express it.
#
# ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A
# claim is not sticky. Filed once and skipped next cycle, the floor
# lapses and the actuator goes silently back to whatever was pushing the
# peak. Which of the higher-priority claims outranks it is arbiter.py's
# business, not this block's - and the answer is already right: a
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
# commands discharge or zero, neither of which can create offtake, while
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
# therefore bound by this floor. That is §9.1's "money outranks the
# maintenance schedule", now as a consequence of the priorities.
#
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
# legacy single-entity path: there is no telegram stream, so
# `p1.averager` has no open block, so the guard would be BLIND on every
# cycle forever - and blind means "no charging". That is not a
# conservative default, it is a controller that silently stops charging
# on every install that has not opted in to P1 yet; test_clamp.py's
# export case caught it doing exactly that. The billed quantity simply
# is not measured on that path, and the honest report is NOT COVERED -
# the same answer, for the same reason, that the status page already
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
dev_top = self.dev_max if self.dev_max is not None else max_w
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self.peak.project(self.p1.averager, now),
batt_w=self.batt,
cap_w=float(self.o.get("peak_cap_w", 3500)),
reading=self.peak.peak(now),
achievable_w=min(max_w, abs(float(dev_top))),
max_hold_s=self.p1.averager.max_hold_s,
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
standing, max_w, self.freeze_wd.cfg),
)
peak_claim = (peakguard.claim(self.peak_guard)
if self.peak_guard is not None else None)
if peak_claim is not None:
claims.append(peak_claim)
# ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a
# guard that appended one every 15 s for a whole quarter would push the
# cause of the event off the end of the log that is meant to explain it.
# SAFETY-03 logs every engagement because a clamp engagement IS the
# count that matters there; here the count is the seconds, and the
# engagement's own reason line already carries them.
engaged_now = self.peak_guard is not None and self.peak_guard.engaged
if engaged_now != self._peak_engaged:
self._peak_engaged = engaged_now
if self.peak_guard is not None:
self.log_event(self.peak_guard.explain())
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
claims.append(Claim.set("maintenance", P_MAINTENANCE,
@@ -383,9 +525,44 @@ class Controller:
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
self.log_event("peak at risk - maintenance charge shaping suspended")
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
# above the winner's priority, and most restrictive wins - so a peak
# event during a maintenance charge window files `lo > hi`, which is
# a CONTRADICTION and resolves to 0 W. The battery would park in the
# exact quarter it should be shaving. Priority does not save it:
# clause 2 has no notion of a higher-priority limit overriding a
# lower one, and it should not - "most restrictive" is what makes the
# safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
# tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone -
# and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per cycle
# for as long as the event lasts and flush the 40-line action log
# of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
@@ -500,6 +677,19 @@ class Controller:
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
# --- TARIFF-01, and it is LAST on purpose -----------------------------
# Banking a quarter is bookkeeping about a moment that has already
# happened; a slow SD card must never sit between the meter and the
# setpoint. Its position is unconstrained in the way SAFETY-03's and
# SAFETY-18's are not - it grades neither `target` nor `standing` and
# reads only `self.p1`, which no line in this method writes.
self.peak.sync(self.p1)
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -583,6 +773,59 @@ class Controller:
f"for was itself out of range."),
})
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal
- clamp it, then alarm" is SAFETY-03's rule and it is right there: the
loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping
it silently is what let it keep climbing behind a dashboard full of legal
numbers. A peak floor above the inverter's rating is not a defect. It is
a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W
against a 5000 W unit - and alarming on the ordinary case is how an
operator learns to dismiss the notification that matters. So an
engagement is an ordinary control action: it goes to the §15 action log
and the status page, and no further.
What DOES earn the operator's attention is saturation: the guard asked
for more discharge than the inverter can give, so the quarter is going
over the ceiling anyway and the controller has already done everything
available to it. That is the one state where the money is going to be
spent and a human is the only remaining actuator.
Edge-triggered, and de-asserting arms it again - the same shape as
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
per episode, not one per cycle.
"""
g = self.peak_guard
saturated = bool(g is not None and g.saturated)
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
self.log_event("PEAK GUARD SATURATED - " + g.reason)
_LOG.error(
"SAFETY-07 - %s. The guard is asking for everything the inverter "
"has and the quarter is still projected over the ceiling: this "
"quarter will set or raise the month's billed capacity peak. The "
"controller is STILL RUNNING and still shaving; there is nothing "
"further it can do. Shed load.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_saturated",
"title": "GoodWe: the capacity-peak guard has run out of inverter",
"message": (
f"{g.reason}.\n\nThe battery is being commanded to everything "
f"it can give and the quarter is STILL projected over the "
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
f"the month's billed capaciteitstarief peak. This is not a "
f"controller fault - the load is simply larger than the "
f"inverter. Shedding load in the next few minutes is the only "
f"thing that changes the bill."),
})
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -837,6 +1080,61 @@ class Controller:
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
f"while the command is over "
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
# TARIFF-01. The month's billed peak, and - the load-bearing half -
# a DIFFERENT row when the store could not answer. "no peak yet" is
# a normal state on the 1st; "unreadable" is a broken instrument, and
# a status page that spelled them the same way would let a 0 W read
# look like a quiet month right up until SAFETY-07 shaved nothing.
_pk = self.peak.peak()
if _pk.is_unreadable:
out.append({"ok": False, "warn": True,
"text": "capacity-tariff peak: " + _pk.explain()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac() or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok,
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
+ f"; {self.peak.recorded} quarter(s) banked"
+ f"; on start: {self.peak.restore_state}"})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
# would show a green line over a guard that is switched off. `None`
# is a fourth state on purpose: before the first cycle the guard has
# concluded nothing, and saying "idle" then would be a claim about a
# decision that has not been taken.
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True,
"text": "peak guard: no decision yet - the first "
"control cycle has not run"})
elif _pg.saturated:
out.append({"ok": False, "warn": False,
"text": "PEAK GUARD SATURATED - " + _pg.reason})
elif _pg.blind:
out.append({"ok": False, "warn": True, "text": _pg.explain()})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
# quarter-hour average is measured from P1 telegrams and this path
# has none - and a row that only rendered where the guard runs would
# leave the one install that is unprotected showing nothing at all.
# Same answer, same reason, as the freeze detector's NOT COVERED row
# a few lines up.
out.append({"ok": False, "warn": True,
"text": f"peak guard: NOT COVERED on meter_source "
f"{o.get('meter_source')} - the quarter-hour "
f"average is measured from P1 telegrams and this "
f"path has none, so a runaway cannot be capped"})
rows = [("battery SoC", self.soc, o.get("soc_entity")),
("battery power", self.batt, o.get("batt_entity"))]
if not self.p1_enabled:
@@ -944,6 +1242,7 @@ class Controller:
("Maintenance phase", self.maint.phase),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
+68
View File
@@ -286,6 +286,26 @@ class QuarterAverager:
"""Offtake watt-seconds accumulated in the open block so far."""
return self._acc
@property
def held_net_w(self) -> float:
"""The signed net power currently being held forward, in W.
PUBLIC BECAUSE SAFETY-07 DEPENDS ON IT, AND THAT IS THE WHOLE REASON.
`PeakTracker.project()` extrapolates the rest of the quarter from this
value and used to reach it as `getattr(averager, "_last_net", 0.0)`. A
default on a private name fails QUIET and fails in the DISARMING
direction: rename `_last_net` and every projection silently credits 0 W
held, which reads LOW, which puts the peak guard under the ceiling
exactly when the meter says it is over. Filed as TEST-13 item 4 and
fixed here rather than inherited - a money guard may not rest on a read
that can go wrong without a symptom.
Positive is offtake, matching `P1Sample.net_w`. It is the raw held
value, NOT floored at zero: the flooring is the accumulator's rule (only
offtake is billed) and belongs where it is applied, not here.
"""
return self._last_net
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
@@ -353,6 +373,54 @@ class QuarterAverager:
self._last_pp = sample.per_phase_w
return closed
# -- surviving a restart (TARIFF-01) ------------------------------------
def snapshot(self) -> dict:
"""The open block's whole state, as JSON-safe primitives.
⚠️ `last_t` is the HELD SAMPLE'S OWN timestamp and is stored as it is,
not refreshed to "now". That is what makes a restart integrate the gap
under exactly the `max_hold_s` rule a running process would have used:
a short restart is a plateau, a long one leaves uncovered seconds that
drag the closed block's average down. Rewriting it to the restore time
would silently credit the whole outage at the last value seen before it.
"""
return {
"block": self._block,
"acc": self._acc,
"pp_acc": list(self._pp_acc),
"elapsed": self._elapsed,
"last_t": self._last_t,
"last_net": self._last_net,
"last_pp": list(self._last_pp) if self._last_pp is not None else None,
"phases": self.phases,
}
def restore(self, state: dict) -> bool:
"""Put a `snapshot()` back. False if it does not fit this averager.
A phase-count change is a reconfiguration, not a resumption: the
per-phase accumulator would be reshaped mid-block and the two halves
would not be the same measurement. Refusing is the honest answer, and
the caller reports starting fresh rather than pretending it resumed.
"""
try:
if int(state.get("phases", self.phases)) != self.phases:
return False
pp_acc = list(state.get("pp_acc") or [])
if len(pp_acc) != self.phases:
return False
self._block = None if state["block"] is None else int(state["block"])
self._acc = float(state["acc"])
self._pp_acc = [float(v) for v in pp_acc]
self._elapsed = float(state["elapsed"])
self._last_t = None if state["last_t"] is None else float(state["last_t"])
self._last_net = float(state["last_net"])
lp = state.get("last_pp")
self._last_pp = tuple(float(v) for v in lp) if lp is not None else None
return True
except (KeyError, TypeError, ValueError):
return False
# --------------------------------------------------------------------------- #
# what the rest of the add-on talks to
+517
View File
@@ -0,0 +1,517 @@
"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
add-on that is allowed to spend the loop's freedom to save money.
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
calendar month - and banks it where a restart cannot reach. It files no claim and
commands nothing, and says so in its own first paragraph. This module is the
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
else: no writes, no I/O, no clock, no state between calls. Given a projection and
two measured numbers it returns a decision; `main.py` files it.
------------------------------------------------------------------------------
THE HAZARD, WITH THE EURO FIGURE
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
no averaging and no forgiveness - the month's single worst quarter is the bill.
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
itself: a meter that answers 200 OK forever with a document that never changes,
which every freshness mechanism in this stack reads as healthy while the loop
integrates a constant error to the rail and holds it there. freeze.py's own
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
a 900 s billing quarter. That is about nine minutes of human reaction time and
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
This is the cap. It is deliberately NOT a second freeze detector: it consumes
freeze.py's shipped predicate rather than transcribing it.
------------------------------------------------------------------------------
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
the projected quarter average approaches the soft limit; hard clamp on charging
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
by the peak guard; only charging and import are."
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
shape that is:
* incapable of capping discharge - a floor never stops the loop discharging
HARDER, so 9.1's asymmetry is structural here rather than remembered;
* incapable of capping export, for the same reason;
* a hard clamp on charging whenever the floor is above the standing charge;
* pre-emptive discharge whenever the floor is above zero.
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
not dragged off zero by this floor. That is the correct order - a controller
pushing power the wrong way is wrong however much money it is trying to save.
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
is exactly what makes the safety bounds unconditional and must not be changed.
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
quarter of the month it should be shaving hardest. The rule for two limits that
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
shaping for as long as this guard is engaged, which is the same thing that branch
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
maintenance schedule" reads as a suppression there rather than as a priority
here. The floor's clamp to `achievable_w` closes the only other pair that could
cross - the P_SAFETY rating bounds.
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
with no edit to this file. Expressing the priority as a claim rather than as an
if/else in main.py is the whole reason that holds.
------------------------------------------------------------------------------
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
the seconds left in the open block. `project()` gives
projected = (acc + held * remaining) / 900
so the held level that would land the quarter exactly on the ceiling is
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
off the meter are the difference between the two:
excess_w = 900 * (projected - ceiling) / remaining
That single expression carries the whole control law, and two properties of it
are the argument for this design:
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
`excess` is what must come off the METER, so it falls to zero the moment the
battery is already taking it - and a guard that disengaged there would hand
the actuator back, watch the offtake spring straight to the raw load, and
engage again next cycle. That is not a hypothetical: it is what the first
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
engage/release cycles in 900 s, and the quarter still finished 49 W over the
limit it had spent a quarter of an hour holding. 10.2 names the cost
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
wear and - per 9.1 - for the peak it is trying to avoid."
`batt_w + excess` IS the quantity to threshold: the offtake there would be
with the battery at zero, less the level this quarter can still afford. Read
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
is filed for as long as a positive floor is needed and decays continuously
to nothing as the load falls. So there is still no hysteresis constant in
this file - not because oscillation was ruled out by argument, but because
the thresholded quantity is the one that does not oscillate.
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
GROWS. This is the answer to the degeneracy at the top of a block, where the
projection is an extrapolation from almost no evidence: the guard is at its
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
once most of the quarter is behind it and the projection is nearly a
measurement. The formula supplies that; no minimum-evidence threshold was
invented, because inventing one would delay the guard in the case it exists
for - a sustained rail from second zero.
The floor itself is then
floor_w = batt_w + excess_w
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
available. The measured one is right for two independent reasons. First, 9.1's
settled constraint is that the utility bills what FLOWED - the arithmetic
`grid = load - batt` is true of delivered power and false of intent. Second, and
concretely: when the inverter cannot follow (the saturation case control.py
already freezes the integrator for), a floor built on the command asks for
`command + excess` every cycle while the command never arrives, and winds itself
to the rail; a floor built on the measurement asks for `delivered + excess`,
which stops rising the moment delivery stops rising. The delivered form cannot
wind up. The intent form can.
------------------------------------------------------------------------------
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
because the money is spent on `max(month's peak so far, this quarter)`:
ceiling_w = max(peak_cap_w, the month's banked peak)
Once a month's peak already stands at 5013 W, holding every later quarter to
3500 W buys nothing - the bill for that month is already 5013 W - while the
holding itself costs real charging the site wanted. Raising the ceiling to a peak
that has already been paid for is what stops one bad quarter from parking the
battery's charging for three weeks.
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
a peak store that cannot be read makes this guard STRICTER and never looser. That
direction is the whole point: tariff.py's `PeakReading` exists because a database
that did not answer must not become a number, and the number it would have become
here - a zero peak - is exactly the one that reads as "no peak recorded, full
freedom". It is also why this module does NOT consume `headroom_w()`, whose
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
headroom, and full headroom is a guard that is not there.
------------------------------------------------------------------------------
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
A projection is evidence only if the quantity under it was actually measured.
Three ways it is not, all of them checked BEFORE the projection is graded,
because grading a stimulus where the quantity it grades is degenerate is how a
guard passes its own tests while being switched off:
* NO OPEN BLOCK. `project()` answers a fresh averager with
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
compares as comfortably under any ceiling. That is not "the quarter is fine",
it is "there is no quarter". Graded as a number it silently disarms the guard
on exactly the startup path where nothing else is watching.
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
meter's last value forward is how a fabricated peak gets onto a permanent
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
samples keep ARRIVING, so coverage stays perfect and the projection is
confident - and if the stuck value is an export, the projection is a
confident ZERO while the real meter runs at the rail. This is the case
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
quantity is unobservable. It is the minimal action that closes the hazard, and it
is one-directional - forbidding charging can only ever lower delivered offtake,
never raise it - so a false blindness costs a charge the site wanted and cannot
cost energy the pack did not want to give. Contrast the graded branch, which can
force discharge; blindness deliberately does not earn that, because there is by
definition no measurement to size it from.
------------------------------------------------------------------------------
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
false trip parks the battery; a false alarm costs an alert." That reasoning is
right for a DETECTOR whose negative case had to be established on a rig run. It
does not carry to this guard, and the two committed captures say so:
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
quarters comes near engaging. test_peakguard.py re-measures that every run
from the committed capture rather than quoting it here.
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
projection every cycle and releases the instant the projection drops back
under the ceiling. There is no state to clear and no operator to wake. A
false engagement costs one cycle of discharge, not a parked battery - which
is precisely the cost SAFETY-18 could not bound and this one can.
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
it, so the worst thing a bug in this file can do is fail to relax a floor -
and a floor is bounded above by `achievable_w`, which main.py takes from the
same `max_w` and device rating the arbiter already enforces.
So it trips. It is also the only guard here whose trigger IS the billed quantity
itself, measured from parsed P1 telegrams, rather than a proxy for it.
------------------------------------------------------------------------------
SATURATION IS REPORTED, NOT ALARMED AS A BUG
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
notification on that - not because the controller is broken, but because it is
the one case where the money WILL be spent and the controller has already done
everything it can. An engagement that is not saturated is an ordinary control
action and goes to the action log, like the clamp's engagements do.
------------------------------------------------------------------------------
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
already owns; `achievable_w` off `max_w` and the device rating the arbiter
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
mismatch trap 14's own warning box describes.
------------------------------------------------------------------------------
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
below it consults the state of charge - `soc_floor` belongs to the maintenance
cycle, and freeze.py already records that for the grid-following loop "the BMS
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
keep asking as long as the meter says the quarter is over the ceiling.
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
reason this must not go to a client install before SAFETY-24 lands: on the
reference site the BMS is a known quantity and an operator is present, and on a
client site neither is true.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
The sum/max/net reduction is an open product question with the owner. TARIFF-01
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from .arbiter import P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page
# and the action log both have to say WHICH, and "blind" alone is unactionable.
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
# Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
SOURCE = "peak"
@dataclass(frozen=True)
class PeakDecision:
"""What the guard concluded this cycle, with everything it concluded it from.
Every field is derived from a measurement taken in the same cycle. There is
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
reason: an accessor that turns "not measured" into a number re-creates the
defect the type exists to remove.
"""
engaged: bool
floor_w: float | None # the `lo` the claim carries; None = no claim
ceiling_w: float
ceiling_from: str
blind: str | None = None
projected_w: float | None = None
excess_w: float = 0.0
wanted_w: float | None = None # the floor before `achievable_w` clamped it
saturated: bool = False
reason: str = ""
def explain(self) -> str:
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return self.reason
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
return the cap, which is the tighter answer. A failed read therefore makes
the guard stricter, never looser - see the module note.
"""
cap = float(cap_w)
if (reading is not None and getattr(reading, "is_observed", False)
and reading.peak_w is not None and float(reading.peak_w) > cap):
return float(reading.peak_w), CEILING_MONTH_PEAK
return cap, CEILING_CAP
def excess_w(projection, ceiling_w) -> float:
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
The whole control law, and the only place it is written down:
excess = 900 * (projected - ceiling) / remaining
Positive means the quarter is projected over; zero or negative means it is
not. `remaining <= 0` is not a large number, it is an undefined one - a
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
than dividing.
"""
remaining = QUARTER_S - float(projection.elapsed_s)
if remaining <= 0.0:
return 0.0
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
def uncovered_s(projection) -> float:
"""Seconds of the open block the meter did not actually cover.
Derived from what `Projection` already carries rather than re-measured:
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
"""
return max(0.0, float(projection.elapsed_s)
* (1.0 - float(projection.covered_frac)))
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
"""Why this projection may not be graded, or None if it may be.
Evaluated BEFORE the projection is compared to anything. Each of the three is
a way for the projected number to be confident and wrong, and the first - a
fresh averager's all-zero `Projection` - is confident, wrong AND under every
ceiling there is.
"""
if frozen:
return BLIND_FROZEN
if projection.block_start is None:
return BLIND_NO_BLOCK
gap = uncovered_s(projection)
if max_hold_s is not None and gap > float(max_hold_s):
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
return None
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
`batt_w` MEASURED battery power, positive = discharge. None when the
sensor for it is missing - and then there is nothing to size a
floor from, so no floor is invented.
`achievable_w` the largest discharge the arbiter could actually grant
(`min(max_w, device max)`). The floor is clamped to it so this
claim can NEVER produce `lo > hi` in the resolver - a
contradiction there resolves to 0 W, which would park the
battery in the exact minute it should be discharging hardest.
"""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
ceil_top = abs(float(achievable_w))
blind = blindness(projection, max_hold_s, frozen)
if blind is not None:
# 0.0, not a sized floor. There is no trustworthy measurement to size one
# from; the claim that remains is "do not add to the offtake you cannot
# see", which is one-directional and cannot cost pack energy.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None,
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
f"while the billed quarter cannot be measured; commanding no "
f"charging (floor 0 W)"))
projected = float(projection.projected_w)
if batt_w is None:
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
# 0 W. Filing nothing is the honest answer: a floor sized from a
# measurement that is not there is a made-up number.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
reason="no battery measurement to size a floor from")
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
# A block with no seconds left cannot be influenced. This is degenerate
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
# the battery's own current output would be a claim that says nothing.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
reason="the quarter has no seconds left to influence")
ex = excess_w(projection, ceiling)
wanted = float(batt_w) + ex
if wanted <= 0.0:
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
# `excess` is the watts that must come off the METER, so it goes to zero
# as soon as the battery is already removing them - and a guard that
# disengaged there handed the actuator straight back, the offtake sprang
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
# billed quarter that bang-bang cycled 17 times in 900 s and left the
# quarter 49 W over the limit it had just spent a quarter of an hour
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
#
# `batt_w + excess` is the offtake that would exist with the battery at
# zero, minus the level the quarter can afford. Testing THAT is the same
# arithmetic read as a level rather than as a switch: the claim is filed
# for as long as a positive floor is needed and decays continuously to
# nothing as the load falls, so there is still no hysteresis constant -
# the quantity being thresholded is simply the right one.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
floor = min(wanted, ceil_top)
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated,
reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
f"{ceiling:.0f} W ceiling ({from_where}) with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
f"floor {floor:+.0f} W"
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
f"only give {ceil_top:.0f} W" if saturated else "")))
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate
transcribed into its own test is one copy plus a future disagreement about
what the check means, and the copy that drifts is always the grading one.
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
back to its historical broken form and all twelve suites stayed green.
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
with no notion of one limit outranking another - which is precisely what
makes the safety bounds unconditional and must not be changed. Two crossing
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
P_MAINT_SHAPE 40 does not save it and never could.
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
there and `peak_at_risk()` answers False forever. Before this card the
suspension therefore could not fire on the very site that needed it. The
second term is the MEASURED guard, which is always present wherever P1 is.
Deleting either term - or re-gating the whole thing on the forecast - parks
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
part 7 drives the real cycle with each of those mutations in place.
"""
if peak_at_risk(peak_forecast_w, float(cap_w)):
return True
return decision is not None and decision.engaged
def claim(decision: PeakDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when the guard is idle.
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
export are never capped by the peak guard" expressed as a fact about the
object rather than as a rule somebody has to remember.
"""
if not decision.engaged or decision.floor_w is None:
return None
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind else "quarter peak"))
File diff suppressed because it is too large Load Diff
+924
View File
@@ -0,0 +1,924 @@
"""TARIFF-03 - static day/night and fully-fixed tariffs, first-class.
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE SPEC SAYS. specs.md §2.2 puts the
price machinery at `ems/tariff/...`. That module layout does not exist and is
not being built; the product owner's decision of 2026-08-30 is that everything
ships in `goodwe_controller/app/`, next to price.py, arbiter.py and validate.py.
The BEHAVIOUR §9.2 / §9.3 / §11.4 / §10.1 describes is unchanged and still
authoritative - only the path moved.
WHY THIS TICKET EXISTS, in §9.2's own words: "a large share of the installed
base is on [a static profile], and an EMS that only works under dynamic pricing
is an EMS for a minority." The §9.2 product table maps four products onto four
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
Fully fixed single price SELF-CONSUMPTION ONLY <- here
THE PLACEMENT DECISION, AND THE TWO REASONS IT IS NOT A PROVIDER PLUGIN
=======================================================================
The obvious move is a sixth `@register`ed `PriceProvider` alongside
`generic_dayahead` and `manual_csv`. It was considered and rejected, on two
grounds that are mechanical rather than aesthetic:
1. A PROVIDER'S PRODUCT IS FIXED AT CLASS-DEFINITION TIME AND THIS ONE IS NOT.
`PriceProvider.capabilities()` is a `classmethod` taking no options, and
`load_prices()` reads `caps.product` to stamp the series. But §9.2 makes
STATIC and FIXED two DIFFERENT products with two different required EMS
behaviours, and which of the two a site is on is a property of the
OPERATOR'S DATA - one period or several - not of any class. A plugin would
have to declare one of them and be wrong for half its users, or the
framework would have to grow a per-call product override, which is a
change to TARIFF-02's contract for the benefit of one caller.
`StaticProfile.product` derives it from the periods instead (see below).
2. THE PLUGIN FRAMEWORK EXISTS TO SOLVE A PROBLEM THIS PATH DOES NOT HAVE.
`capabilities().sources` / `assemble()` exist because a FETCHED figure may
or may not already contain the supplier's markup, and adding it twice is
invisible (price.py, trap 1). A static profile's numbers are typed by the
operator as all-in; there is no publisher to disagree with, so the whole
`RawPoint` -> `assemble()` -> `PricePoint` pipeline would be an identity
function wrapped in a declaration nobody can check.
⚠️ WHAT IS *NOT* A REASON: "it is offline". `manual_csv` is offline and is a
plugin, correctly. Offline-ness is not what separates these two.
So the static profile sits UNDER the provider framework, as the layer §11.4
already says it is:
§11.4 No price data -> fall back to the static profile, mark the plan
provisional, no arbitrage.
which makes the §9.2 product option and the §11.4 degradation row THE SAME CODE
PATH, differing in exactly one flag. A fully-static site is one that leaves
`price_provider: off` (already the shipped default) and fills in
`static_profile`; a dynamic site whose feed died falls into the same generator
with `provisional=True`. One generator, two call sites, one flag - so the
fallback cannot rot, because it is the path a whole class of installs runs on
every day.
It emits `PricePoint`s in TARIFF-02's canonical `{start, eur_per_kwh,
provisional}` shape (§9.3, "so the optimiser compares like with like"), so
every downstream consumer works unchanged and none of them learns that a
tariff can be static.
HOW "NO ARBITRAGE" IS EXPRESSED SO A FUTURE OPTIMISER CANNOT IGNORE IT
======================================================================
A boolean called `arbitrage_ok` that OPT-04 is asked to remember to check is not
a mechanism; it is a comment with a type. So the numbers an arbitrage strategy
NEEDS - the spread, and which hours are the cheap and dear ends of it - EXIST
ONLY ON THE PERMITTING VARIANT of `Arbitrage`. `refused` carries `.reason` and
nothing else, and reaches for anything else raises `Absence`, the same shape
price.py borrowed from `sim/riglock.py`. An optimiser cannot compute a trade
from a refusal, because it cannot get the inputs out of one.
And the verdict is only obtainable from `TariffPlan.arbitrage(...)`, which takes
`peak_guard_active` as a REQUIRED KEYWORD ARGUMENT. §9.1 and §10.1 put peak
shaving (priority 2) above price arbitrage (priority 3) - the capacity tariff is
a network cost charged whatever the energy product is - and the way that
ordering is made to survive OPT-04 landing is that OPT-04 cannot obtain a spread
without first stating whether the peak guard is holding. It is not free to
forget the question; the call does not compile without an answer.
⚠️ FOUR REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
1. the peak guard is active §10.1: priority 2 outranks priority 3
2. no price data at all §11.4, and no static_profile to fall to
3. the plan is provisional §11.4 fallback / §8.3 unknown hours
4. the series is FLAT max - min == 0, so there is nothing to
arbitrage. THIS is AC2: a fully-fixed
tariff disables arbitrage by ARITHMETIC,
not by a special case keyed on
`PriceProduct.FIXED`. A dynamic feed
that happens to print one price all day
gets refused for the same true reason.
Nothing here decides a trade. The spread is handed over; OPT-04 owns
`cycle_cost_threshold_eur` (§10.1/§14) and what to do about it. ⚠️ Worth knowing
before that lands: on the Belgian day/night numbers in DOCS.md the whole-tariff
spread is 0.08 EUR/kWh, which is BELOW the shipped 0.10 threshold - so a
correct OPT-04 will decline to cycle for the tariff alone on such a site. That
is a product finding, not a bug here.
WHAT STAYS ON WHEN ARBITRAGE IS OFF (AC2, and §10.1 in full): priorities 1
(self-consumption), 2 (peak shaving), 4 (reserve management) and 5 (load
shifting) are untouched by any refusal above. Only priority 3 goes away. In
0.5.x that is literally true because none of them reads a price: the grid
following loop, the capacity-tariff peak tracker and the freeze/sign watchdogs
all run off P1 and know nothing about this module.
TIME-WINDOW SHIFTING, AND THE ONE SHIFTABLE LOAD THAT EXISTS TODAY
==================================================================
§9.2's handling for a two-period tariff is "time-window shifting", and §10.1
priority 5 is load shifting. The add-on has exactly one shiftable load in
0.5.x: the maintenance cycle's cheap-window grid top-up. `static_status()` is
what main.py reads for it, and `cheap_now()` is the rule:
cheap <=> the price now is STRICTLY BELOW the mean over the horizon
⚠️ STRICTLY BELOW, AND THE STRICTNESS IS AC2. Under a fully-fixed tariff every
hour equals the mean, so `<=` would make EVERY hour a cheap window and a fixed
tariff site would top the battery up from the grid at any hour of the day -
which is the exact opposite of "self-consumption only". With `<`, a flat series
has no cheap window at all, derived rather than special-cased. (The Home
Assistant `price_now_entity`/`price_avg_entity` pair main.py has always used
computes `now <= avg`; the difference is deliberate and is why this is not
simply the same expression.)
⚠️ THE MEAN IS TAKEN OVER SEVEN LOCAL DAYS, not over the 48 h forecast horizon,
and that is not tidiness. Belgian night tariffs usually cover the whole weekend,
so a 48 h mean computed on a Friday afternoon is weighted about 2/3 weekend and
the SAME Tuesday 09:00 comes out cheap or dear depending on the day you asked.
Seven days contains each weekday exactly once, so the mean is a property of the
tariff instead of a property of the hour you called at.
DAYLIGHT SAVING, AND WHY THERE IS NO `zoneinfo` HERE EITHER
============================================================
The shipped Alpine image has no `tzdata` (price.py's docstring, and the
Dockerfile), so `ZoneInfo("Europe/Brussels")` raises there. price.py already
carries `brussels()` / `brussels_offset()` - the EU rule, in arithmetic, checked
in review of PR #16 against `zoneinfo` over 149,040 hourly instants with zero
mismatches - and this module uses it rather than growing a second answer to the
same question.
Generation iterates HOURLY IN UTC and renders each instant to local, so a
spring-forward day yields 23 points and an autumn-back day 25, BY CONSTRUCTION.
An implementation that walked 24 local hours would emit 24 on both and misprice
an hour twice a year; `test_static_tariff.py` drives both 2026 transitions and
counts, rather than asserting 24.
⚠️ A WINDOW BELONGS TO THE DAY THE CLOCK READS, NOT THE DAY IT STARTED. A
`22:00-07:00 mon-fri` night window covers Friday 23:00 (Friday) but NOT
Saturday 03:00 - Saturday 03:00 is Saturday's, and Saturday needs its own
period. That is the only rule under which the tiling check below can be
decidable at all, and it is why the shipped Belgian example carries an
explicit weekend row.
VALIDATION: NEVER INVENT A PRICE FOR AN UNCOVERED HOUR
======================================================
For every day type the periods must tile the 1440-minute clock exactly once -
no gap, no overlap - and `parse_static_profile()` refuses anything else with the
uncovered or doubled range named in local time. `validate.py` calls it at
startup, so a malformed profile is a refusal to start, not a surprise at 02:00.
The alternative - defaulting an uncovered hour to some price - is the defect
price.py's whole `_NoData` type exists to prevent, one level down: an invented
number that arbitrage then acts on. A missing profile is not a zero price, and
a zero price is exactly the value that makes everything look free.
"""
import json
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
_Outcome, brussels, load_prices, price_option,
)
_LOG = logging.getLogger("goodwe.tariff.static")
DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
_DAY_INDEX = {n: i for i, n in enumerate(DAY_NAMES)} # Mon=0, as weekday()
_DAY_ALIASES = {
"weekday": "mon-fri", "weekdays": "mon-fri", "week": "mon-fri",
"weekend": "sat-sun", "weekends": "sat-sun",
"all": "mon-sun", "daily": "mon-sun", "every": "mon-sun", "*": "mon-sun",
}
MINUTES_PER_DAY = 1440
# The mean that `cheap_now()` grades against is taken over this many local days.
# Seven, so each weekday appears exactly once - see the module docstring. It is
# a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three
# places option defaults live, and this describes the Belgian week rather than
# the site.
MEAN_WINDOW_DAYS = 7
# The only timezone a `static_profile` may name. See the docstring: there is no
# tz database in the shipped image, so anything else cannot be honoured and is
# refused rather than silently treated as Brussels.
SUPPORTED_TZ = ("europe/brussels", "cet", "cest", "")
class ProfileInvalid(Exception):
"""A `static_profile` that cannot be turned into a tariff.
Refused at startup by validate.py. Carries a message naming the key and the
fix, because the operator who typed it is the only one who can repair it.
"""
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
def _minutes(value, field: str) -> int:
""""HH:MM" -> minutes past local midnight. `to: "24:00"` means end-of-day."""
text = str(value).strip()
if not text:
raise ProfileInvalid(f"{field} is empty; write it as \"HH:MM\", e.g. "
f"\"07:00\"")
parts = text.split(":")
if len(parts) != 2:
raise ProfileInvalid(
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
f"guessed at - 7.5 could be 07:30 or 07:50.")
try:
h, m = int(parts[0]), int(parts[1])
except ValueError:
raise ProfileInvalid(f"{field} is {value!r}; \"HH:MM\" needs two "
f"integers, e.g. \"07:00\"")
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
raise ProfileInvalid(
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
f"midnight at the end of the day.")
return h * 60 + m
def _days(value, field: str) -> frozenset:
"""A day selector -> a frozenset of weekday() indices. Mon=0 .. Sun=6.
Accepts a list (`["mon", "tue"]`), a comma list (`"sat,sun"`), a range
(`"mon-fri"`), the aliases in `_DAY_ALIASES`, and any mixture.
"""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Name the days this period applies to - "
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
f"defaulted to every day, because a Belgian night tariff that "
f"silently covered the weekend too would misprice 48 h a week with "
f"nothing reporting it.")
tokens = []
if isinstance(value, (list, tuple)):
for item in value:
tokens.extend(str(item).strip().lower().split(","))
else:
tokens.extend(str(value).strip().lower().split(","))
out = set()
for token in tokens:
token = _DAY_ALIASES.get(token.strip(), token.strip())
if not token:
continue
if "-" in token:
a, _, b = token.partition("-")
a, b = a.strip(), b.strip()
if a not in _DAY_INDEX or b not in _DAY_INDEX:
raise ProfileInvalid(
f"{field}: {token!r} is not a day range. Days are "
f"{', '.join(DAY_NAMES)}; a range is written \"mon-fri\".")
i, j = _DAY_INDEX[a], _DAY_INDEX[b]
# A range may wrap (`fri-mon`), because a Belgian weekend tariff is
# often written that way.
out.update((i + k) % 7 for k in range((j - i) % 7 + 1))
else:
if token not in _DAY_INDEX:
raise ProfileInvalid(
f"{field}: {token!r} is not a day. Use "
f"{', '.join(DAY_NAMES)}, a range like \"mon-fri\", or one "
f"of {', '.join(sorted(_DAY_ALIASES))}.")
out.add(_DAY_INDEX[token])
if not out:
raise ProfileInvalid(f"{field} selected no days at all")
return frozenset(out)
def _price(value, field: str) -> float:
"""The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed."""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Every period needs an all-in eur_per_kwh "
f"(energy + supplier markup + taxes, §9.3) - the same basis the "
f"dynamic providers are assembled onto, so the two are comparable.")
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
try:
out = float(value)
except (TypeError, ValueError):
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
if not math.isfinite(out):
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
raise ProfileInvalid(
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
f"0.34 - which would make every hour look like an emergency. Same "
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
return out
@dataclass(frozen=True)
class Period:
"""One priced window of the local week. Half-open [start, end) on the clock.
`end <= start` means the window wraps past local midnight (22:00-07:00), and
`days` selects the day THE CLOCK READS, not the day the window opened - see
the module docstring.
"""
name: str
start_min: int
end_min: int
days: frozenset
eur_per_kwh: float
@property
def wraps(self) -> bool:
return self.end_min <= self.start_min
def covers(self, weekday: int, minute: int) -> bool:
if weekday not in self.days:
return False
if not self.wraps:
return self.start_min <= minute < self.end_min
return minute >= self.start_min or minute < self.end_min
@property
def words(self) -> str:
def hhmm(m):
return f"{m // 60:02d}:{m % 60:02d}"
days = ",".join(DAY_NAMES[d] for d in sorted(self.days))
return (f"{self.name} {hhmm(self.start_min)}-"
f"{hhmm(self.end_min if self.end_min else MINUTES_PER_DAY)} "
f"{days} @ {self.eur_per_kwh:.4f}")
class StaticProfile:
"""A validated two-or-more-period tariff. Immutable, pure, I/O-free."""
def __init__(self, periods):
self.periods = tuple(periods)
if not self.periods:
raise ProfileInvalid(
"static_profile has no periods. A profile with no periods is "
"not a fixed tariff, it is an absent one - leave the option "
"empty to say that on purpose (§11.4 then applies).")
_check_tiling(self.periods)
# -- what kind of tariff this is, DERIVED --------------------------------
@property
def prices(self) -> tuple:
return tuple(p.eur_per_kwh for p in self.periods)
@property
def product(self) -> PriceProduct:
"""§9.2's product, derived from the DATA rather than declared.
One distinct price across every period is a fully-fixed tariff whatever
the operator called the rows; two or more is a static profile. Nobody
has to tick a "this is fixed" box and nobody can tick it wrongly.
"""
return (PriceProduct.FIXED if len(set(self.prices)) == 1
else PriceProduct.STATIC)
# -- lookups -------------------------------------------------------------
def at(self, instant: datetime) -> Period:
"""The period covering an INSTANT. Tiling makes this total."""
local = brussels(instant)
minute = local.hour * 60 + local.minute
for p in self.periods:
if p.covers(local.weekday(), minute):
return p
# Unreachable given _check_tiling, and it stays an exception rather than
# a default for the reason the module docstring gives.
raise Absence(
f"no period covers {local.isoformat()} - the tiling check that "
f"makes this impossible has been weakened")
def price_at(self, instant: datetime) -> float:
return self.at(instant).eur_per_kwh
def next_change(self, instant: datetime, *, limit_days: int = 8):
"""The next instant at which the period NAME changes, or None.
Coarse 15-minute scan to bracket the change, then a 1-minute refine, so
this is ~800 lookups rather than ~11,500. Returns UTC.
"""
u = instant.astimezone(timezone.utc).replace(second=0, microsecond=0)
# ⚠️ KEYED ON (name, price), NOT ON THE NAME. Two rows may legitimately
# share a name - a Belgian night tariff is often written as a weekday
# row and a weekend row both called "night" - and the tariff has not
# changed when the clock crosses between them. But two rows with one
# name and DIFFERENT prices is a change an operator must see, and a
# name-only comparison would report the next change as never.
here = self._key(u)
coarse = timedelta(minutes=15)
end = u + timedelta(days=limit_days)
probe = u
while probe < end:
probe += coarse
if self._key(probe) != here:
fine = probe - coarse
while fine < probe:
fine += timedelta(minutes=1)
if self._key(fine) != here:
return fine
return probe
return None
def _key(self, instant):
p = self.at(instant)
return (p.name, p.eur_per_kwh)
# -- the canonical series ------------------------------------------------
def series(self, since: datetime, until: datetime, *,
provisional: bool) -> list:
"""Hourly `PricePoint`s over [since, until), in §9.3's canonical shape.
⚠️ ITERATES IN UTC. That is what makes a spring-forward day 23 points and
an autumn-back day 25 without anything here knowing the dates.
"""
a = since.astimezone(timezone.utc).replace(minute=0, second=0,
microsecond=0)
b = until.astimezone(timezone.utc)
out = []
u = a
while u < b:
out.append(PricePoint(start=brussels(u),
eur_per_kwh=round(self.price_at(u), 6),
provisional=bool(provisional)))
u += timedelta(hours=1)
if not out:
raise ProfileInvalid(
f"static_profile.series({since.isoformat()}, "
f"{until.isoformat()}) spans no whole hour. An empty series is "
f"not constructible downstream on purpose (price.py trap 3).")
return out
@property
def words(self) -> str:
kind = ("fully fixed" if self.product is PriceProduct.FIXED
else f"static, {len(set(self.prices))} price levels")
return f"{kind}: " + "; ".join(p.words for p in self.periods)
def _check_tiling(periods) -> None:
"""Every day type must be covered by EXACTLY ONE period, every minute.
⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only
way to serve it is to invent one. An overlap means two prices for one hour
and a silent winner decided by list order - the "two writers, one of which
quietly wins" shape validate.py exists to refuse.
"""
for weekday in range(7):
gap_from = over_from = None
for minute in range(MINUTES_PER_DAY):
n = sum(1 for p in periods if p.covers(weekday, minute))
if n == 0 and gap_from is None:
gap_from = minute
if n > 1 and over_from is None:
over_from = (minute, n)
if n != 0 and gap_from is not None:
_raise_gap(weekday, gap_from, minute)
if n <= 1 and over_from is not None:
_raise_overlap(weekday, over_from, minute, periods)
if gap_from is not None:
_raise_gap(weekday, gap_from, MINUTES_PER_DAY)
if over_from is not None:
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods)
def _hhmm(m):
return f"{m // 60:02d}:{m % 60:02d}"
def _raise_gap(weekday, start, end):
raise ProfileInvalid(
f"static_profile leaves {DAY_NAMES[weekday]} "
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
f"price. Refused rather than defaulted: an invented price is one an "
f"optimiser would trade on, and §11.4 already says what a missing price "
f"means. Add a period covering it - remember a window written "
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
def _raise_overlap(weekday, over, end, periods):
start, n = over
names = [p.name for p in periods if p.covers(weekday, start)]
raise ProfileInvalid(
f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
f"one hour means one of them silently wins on list order. Narrow one of "
f"the windows or split the day selector.")
def parse_static_profile(value):
"""`static_profile` -> a `StaticProfile`, or None when unset.
Accepts what the option can actually arrive as:
* `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`.
* a JSON string - what config.yaml's flat `str?` option
carries on a Supervisor install.
* a dict `{"periods": [...]}` - already-parsed, from /data/options.json
under a future nested schema, from
sim/inject.py, or from a check.
* a bare list of period dicts - the same thing without the wrapper.
Raises `ProfileInvalid`; never returns a half-built profile.
"""
if value is None:
return None
if isinstance(value, str):
text = value.strip()
if not text or text.lower() in ("null", "none", "off"):
return None
try:
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"static_profile is not valid JSON ({e}). It is a JSON object "
f"with a \"periods\" list, on one line, e.g.\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty for a dynamic-only or price-free site.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
if tz not in SUPPORTED_TZ:
raise ProfileInvalid(
f"static_profile.timezone is {value.get('timezone')!r}. Only "
f"Europe/Brussels is supported: the shipped image carries no tz "
f"database (see app/price.py), so any other zone could not be "
f"honoured and would be silently treated as Brussels - which is "
f"wrong by an hour for part of the year.")
rows = value.get("periods")
if rows is None:
raise ProfileInvalid(
"static_profile has no \"periods\" list. Even a fully-fixed "
"tariff is written as one period covering 00:00-24:00 all days "
"- one shape for both products, so nothing downstream branches "
"on which kind of site this is.")
else:
rows = value
if not isinstance(rows, (list, tuple)):
raise ProfileInvalid(
f"static_profile.periods is {type(rows).__name__}, not a list")
periods = []
for i, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise ProfileInvalid(
f"static_profile.periods[{i}] is {type(row).__name__}, not an "
f"object with from/to/days/eur_per_kwh")
where = f"static_profile.periods[{i}]"
name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}"
start = _minutes(row.get("from", row.get("start")), f"{where}.from")
end = _minutes(row.get("to", row.get("end")), f"{where}.to")
if end == 0:
end = MINUTES_PER_DAY # "00:00" as an END is end-of-day
if start == end:
raise ProfileInvalid(
f"{where} runs {_hhmm(start)}-{_hhmm(end)}, which is either "
f"zero minutes or the whole day and there is no way to tell "
f"which was meant. Write a full day as 00:00-24:00.")
if start == MINUTES_PER_DAY:
raise ProfileInvalid(
f"{where}.from is \"24:00\"; a period cannot START at "
f"end-of-day. Did you mean \"00:00\"?")
periods.append(Period(name=name, start_min=start,
end_min=end,
days=_days(row.get("days"), f"{where}.days"),
eur_per_kwh=_price(row.get("eur_per_kwh"),
f"{where}.eur_per_kwh")))
return StaticProfile(periods)
# --------------------------------------------------------------------------- #
# The arbitrage verdict. A variant type, because a boolean is a suggestion.
# --------------------------------------------------------------------------- #
class Arbitrage(_Outcome):
"""May an optimiser trade on this tariff, and with what numbers.
* `allowed` - carries `.spread_eur_kwh`, `.cheapest`, `.dearest`,
`.regime`, `.product`. These are the ONLY inputs an
arbitrage strategy needs, and they exist here and nowhere
else.
* `refused` - carries `.reason`. Nothing else. `.spread_eur_kwh` on a
refusal raises `Absence`.
⚠️ NO `__bool__`, for price.py's reason: `if verdict:` must not look like a
guard while being none. Use `.match(allowed=..., refused=...)`, both
handlers required, so the refusal cannot be fallen through.
"""
def match(self, *, allowed, refused):
return {"allowed": allowed, "refused": refused}[self.kind](self)
@staticmethod
def allow(spread, cheapest, dearest, regime, product):
return _Allowed(spread, cheapest, dearest, regime, product)
@staticmethod
def refuse(reason):
if not reason or not isinstance(reason, str):
raise ValueError(
"Arbitrage.refuse() takes a reason a human can act on - §10.2 "
"requires every plan change to carry one, and 'arbitrage did "
"nothing' with no reason is indistinguishable from a bug.")
return _Refused(reason)
class _Allowed(Arbitrage):
kind = "allowed"
def __init__(self, spread, cheapest, dearest, regime, product):
self.spread_eur_kwh = spread
self.cheapest = cheapest # the PricePoint at the low end
self.dearest = dearest # the PricePoint at the high end
self.regime = regime # §8.3 "wide" | "normal"
self.product = product
self.words = (f"spread {spread:.4f} EUR/kWh over "
f"{product.value} ({regime} thresholds)")
class _Refused(Arbitrage):
kind = "refused"
def __init__(self, reason):
self.reason = reason
self.words = f"no arbitrage - {reason}"
# --------------------------------------------------------------------------- #
# The plan: one tariff, whatever produced it
# --------------------------------------------------------------------------- #
class TariffPlan:
"""What this site's tariff looks like right now, and what may be done on it.
`origin` is how the series was obtained, and it is NOT the same question as
`product`:
"provider" a live feed (§9.2 rows 1-2)
"static" the CONFIGURED static_profile (§9.2 rows 3-4)
"fallback" the static_profile standing in for a dead feed (§11.4) -
identical data, `provisional=True`, arbitrage refused
"none" no feed and no profile. §6: the loop keeps regulating.
"""
def __init__(self, read: PriceRead, profile, origin: str):
self.read = read
self.profile = profile
self.origin = origin
# -- derived facts -------------------------------------------------------
@property
def has_prices(self) -> bool:
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
@property
def product(self):
return self.read.match(series=lambda r: r.product,
no_data=lambda _r: None)
@property
def provisional(self) -> bool:
"""§8.3/§11.4, derived from the points rather than asserted."""
return self.read.match(
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True)
def price_at(self, instant: datetime) -> float:
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
return self.read.match(
series=lambda r: r.at(instant).eur_per_kwh,
no_data=lambda r: _absent(r.reason))
def window_at(self, instant: datetime) -> str:
"""The name of the tariff window now (`day`, `night`, ...).
Only a static profile has named windows; a day-ahead series does not,
and inventing one would be a label with no referent.
"""
if self.profile is None:
raise Absence(
"this plan has no static_profile, so it has no named tariff "
"window. Time-window shifting (§9.2) is a static-profile "
"behaviour; a day-ahead series is shifted against prices, not "
"against window names.")
return self.profile.at(instant).name
def mean_eur_kwh(self) -> float:
return self.read.match(
series=lambda r: sum(p.eur_per_kwh for p in r.points) / len(r.points),
no_data=lambda r: _absent(r.reason))
def spread_eur_kwh(self) -> float:
return self.read.match(
series=lambda r: (max(p.eur_per_kwh for p in r.points)
- min(p.eur_per_kwh for p in r.points)),
no_data=lambda r: _absent(r.reason))
# -- the two things a consumer is allowed to act on ----------------------
def cheap_now(self, instant: datetime) -> bool:
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
False on a provisional plan: §8.3 forbids an irreversible action on a
provisional price, and a grid top-up of a battery is about as
irreversible as this add-on gets. False when there is no price at all,
which is the §11.4 row and also main.py's long-standing "fixed-tariff
site: never force a paid grid top-up, wait for sun".
"""
if not self.has_prices or self.provisional:
return False
try:
return self.price_at(instant) < self.mean_eur_kwh()
except Absence:
# An instant outside the series is an hour with no price (§11.4 for
# that hour), not a cheap one.
return False
def arbitrage(self, *, peak_guard_active: bool) -> Arbitrage:
"""May OPT-04 trade, and on what numbers. See the module docstring.
⚠️ `peak_guard_active` IS REQUIRED AND KEYWORD-ONLY. §9.1/§10.1 put peak
shaving above arbitrage, and this is how that ordering survives a
strategy that has not been written yet: the spread is not obtainable
without answering the question.
"""
if not isinstance(peak_guard_active, bool):
raise TypeError(
f"peak_guard_active must be a bool, not {peak_guard_active!r}. "
f"A truthy 'unknown' is how §10.1's priority 2 gets skipped.")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
"shaving (priority 2) above price arbitrage (priority 3) - the "
"capaciteitstarief is a network cost charged whatever the "
"energy product is (§9.1)")
if not self.has_prices:
return Arbitrage.refuse(
f"§11.4: {self.read.reason}. No series exists, so no spread "
f"exists; self-consumption, peak shaving and reserve management "
f"(§10.1 priorities 1, 2 and 4) are unaffected")
if self.provisional:
return Arbitrage.refuse(
f"§11.4: the plan is provisional (origin {self.origin!r}), and "
f"§8.3 forbids an irreversible action on a provisional price. "
f"Load shifting and peak shaving continue")
spread = self.spread_eur_kwh()
if spread == 0.0:
return Arbitrage.refuse(
f"the tariff is flat - every one of the "
f"{len(self.read.points)} hours costs "
f"{self.read.points[0].eur_per_kwh:.4f} EUR/kWh, so buying low "
f"and selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
pts = self.read.points
return Arbitrage.allow(
spread=spread,
cheapest=min(pts, key=lambda p: p.eur_per_kwh),
dearest=max(pts, key=lambda p: p.eur_per_kwh),
regime=self.read.regime,
product=self.read.product)
@property
def words(self) -> str:
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
+ (", provisional" if self.provisional else ""))
def _absent(reason):
raise Absence(
f"this plan observed no prices ({reason}). A number invented here is "
f"the bug price.py's _NoData exists to prevent - 'no data' becoming "
f"'free'. Ask has_prices, or use read.match(...).")
# --------------------------------------------------------------------------- #
# The two entry points
# --------------------------------------------------------------------------- #
def static_plan(opts: dict, since: datetime, until: datetime, *,
provisional: bool = False, now=None) -> TariffPlan:
"""The static-profile plan. PURE, I/O-FREE, no network, no tz database.
This is what main.py calls on the planning tick. It is synchronous on
purpose: `load_prices()` may perform an HTTP fetch, and a fetch on the tick
that feeds the ESP32 heartbeat is a supplier's TLS timeout able to stall the
heartbeat towards layer 1's 30 s veto. Generation is pure, so §2.3 says
regenerate every tick rather than cache, and there is nothing here that can
block.
"""
profile = parse_static_profile(price_option(opts, "static_profile"))
if profile is None:
return TariffPlan(
PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no "
"arbitrage. Self-consumption, peak shaving and reserve "
"management are unaffected"),
None, "none")
points = profile.series(since, until, provisional=provisional)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile",
now or datetime.now(timezone.utc)),
profile, "fallback" if provisional else "static")
def static_status(opts: dict, now: datetime):
"""(cheap, words) for the status page and for main.py's cheap-window flag.
Never raises: a `static_profile` that does not parse is refused at STARTUP
by validate.py, so reaching this with a broken one means somebody handed the
controller a dict the validator never saw. That is worth a warning and a
"no cheap window", not a controller that stops regulating a battery (§6:
absent machinery must not disable the loop).
"""
try:
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
if not plan.has_prices:
return False, "no static profile"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
omission. Wiring a provider-backed plan into the control loop needs a
BACKGROUND refresh (a day-ahead feed publishes once a day; polling it every
30 s would be rude and would put a network timeout on the heartbeat path).
That task belongs with the first strategy that actually trades - OPT-04 -
and this function is the seam it consumes. Everything below it is exercised
by test_static_tariff.py today.
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
price_provider off + none -> no prices at all (§11.4, nothing to
fall back to)
provider series -> the feed (§9.2 rows 1-2)
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
provider no_data + none -> no prices at all
"""
stamp = now or datetime.now(timezone.utc)
pid = str(price_option(opts, "price_provider") or "off").strip()
if pid in ("", "off"):
return static_plan(opts, since, until, provisional=False, now=stamp)
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _quiet_profile(opts):
"""The profile, for window naming, without letting a bad one break a good feed."""
try:
return parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid:
return None
def _degrade(opts, read, since, until, stamp) -> TariffPlan:
"""§11.4: fall back to the static profile, mark provisional, no arbitrage."""
try:
profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable "
"(%s), so there is no tariff at all", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
profile, "fallback")
+702
View File
@@ -0,0 +1,702 @@
"""TARIFF-01 - the monthly capacity-tariff peak, and the record that outlives it.
WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. This module is an INSTRUMENT. It
measures the number the Belgian capaciteitstarief bills on - the highest
15-minute average offtake of the calendar month (Fluvius / Resa / Ores) - and it
writes that number down somewhere a restart cannot reach. It makes no claim,
applies no clamp, and commands nothing. The guard that will read it is SAFETY-07,
and it belongs in `arbiter.py` as a `P_PEAK` claim when it is built. Keeping the
measurement and the actuation apart is the whole reason this file is small.
WHAT WAS ALREADY BUILT, AND WHY THIS CARD IS NOT 8 SP OF NEW ARITHMETIC.
`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. It was unwired and unpersisted - `P1Ingest.blocks` is a
96-entry list in memory that a restart erases. So this file is the persistence
and the monthly reduction, not a second accumulator. Adding one would be adding
a second source of truth for the exact quantity the money depends on.
BLOCKS, NOT A SLIDING WINDOW. The Description says "rolling"; AC 1 says
"15-min block average". They are different numbers and the utility bills the
second one. §9.1 calls the billed unit the "highest 15-minute average offtake",
and §9.1's own worked 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" here means CONTINUOUSLY UPDATED (the open block's partial
average is readable at every sample), not a trailing-15-minutes window. A
trailing window would report 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 would spend battery defending an interval nobody charges for.
DELIVERED POWER, NOT INTENDED POWER. SAFETY-20 has an open question about
whether guards should grade `Decision.target` (pre-clamp intent) or `standing`
(what actually reached the wire). For a capacity tariff there is no question to
ask: the utility's meter integrates the current that flowed. This module never
sees a setpoint at all - it consumes `P1Sample`, which is the meter's own
reading - and that is on purpose. 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).
AN UNREADABLE STORE IS NOT A ZERO PEAK. This is the defect this repo spent a
day removing from `sim/riglock.py`, where `holder()` was rewritten to answer
`observed / not_observed / gone` instead of a partial dict every caller
`.get()`s a default out of. The same trap is here and it is worse, because the
degenerate value is not merely wrong, it is DISARMING: a peak read as 0 W gives
SAFETY-07 the maximum possible headroom and silently switches the guard off at
the moment its own storage is broken. So `peak()` returns a `PeakReading` whose
`state` is one of OBSERVED / NO_RECORD / NOT_OBSERVED, `peak_w` is `None`
unless the state is OBSERVED, and there is no `.get(..., 0)` shaped accessor
anywhere on it. NO_RECORD ("this month has no completed block yet", a normal
state on the 1st) and NOT_OBSERVED ("the database did not answer") are
different types of thing and are never spelled the same way.
CONFIG: NONE ADDED, ON PURPOSE. CFG-03 is open because the option defaults
already live in three places held apart by a tripwire in `test_validate.py`. The
soft limit this measurement is graded against already exists as `peak_cap_w`
(config.yaml, 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 made a fourth
defaults site AND imported the unit mismatch §14's own warning box says must be
caught at startup. So this module takes its limit as a plain constructor
argument in WATTS and config.yaml is untouched by this ticket.
THE DATABASE. SQLite in /data, the add-on's only durable volume, alongside
`store.py`'s state.json. Two tiers from §15, and their retention is the point:
quarter_blocks one row per completed 900 s block INDEFINITE
monthly_peak one row per calendar month PERMANENT
Neither is ever pruned here, and no sweep in this file can prune them - there is
no DELETE statement against either table anywhere in this module. §15's asymmetry
is why: raw samples vanish after 7 days, so a 15-minute aggregate that was not
written AS IT HAPPENED can never be recomputed. `monthly_peak` is maintained as a
derived record - upserted on every block close, and `recompute_month()` re-derives
it from `quarter_blocks` so the two can be PROVEN not to have diverged rather
than assumed not to have.
RESTART MID-BLOCK. The open block's accumulator is memory. A restart at minute 12
that dropped it would under-read the rest of the quarter, and SAFETY-07's clamp
would release exactly when it was most needed. So the accumulator is checkpointed
(`QuarterAverager.snapshot()`) into a single row, at most `CHECKPOINT_S` stale,
and restored on startup. Three cases, all of them exercised in `test_tariff.py`:
* the checkpoint's block is the block containing `now` -> restore it verbatim,
including the held sample's ORIGINAL timestamp, so the first sample after the
restart integrates the gap under the same `max_hold_s` rule a running process
would have applied. A restart longer than `max_hold_s` therefore leaves
uncovered seconds, and a closed block is always divided by the full 900 s, so
the gap DRAGS THE BILLED AVERAGE DOWN instead of being averaged away;
* the checkpoint's block ended while we were down -> that block really happened
and its measured part is real, so it is closed at `acc / 900` and recorded,
then a fresh block is opened. Discarding it would lose measured energy;
* no checkpoint, or one that cannot be read -> start clean and SAY SO. The
return value of `restore()` names which of the three happened; it is never
silently the first one.
Double counting is answered structurally rather than by care: `quarter_start` is
the PRIMARY KEY, and the recorded average may only ever be RAISED by a later
write (`ON CONFLICT ... WHERE excluded.offtake_avg_w > ...`). Replaying the same
block twice is therefore a no-op, and a restart that reprocesses a block with
LESS coverage than the first pass cannot lower a figure already banked.
MONTH BOUNDARY. Calendar months are LOCAL (Europe/Brussels); blocks are stored in
UTC. In August those differ by two hours, so a block starting 2026-08-31 22:15
UTC belongs to SEPTEMBER and putting it in August would move a month's bill.
Block alignment itself needs no timezone - every Belgian offset is a whole number
of hours, so the 900 s UTC grid lands on :00/:15/:30/:45 local on both sides of a
DST transition, which is the argument `QuarterAverager` already carries.
"""
import json
import logging
import os
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo
_LOG = logging.getLogger("goodwe.tariff")
QUARTER_S = 900
# How stale the partial-block checkpoint may be. This is the ONLY thing a crash
# can lose from an open block, and the loss is bounded and one-directional: the
# lost seconds were already integrated into `_acc`, so dropping them makes the
# restored block read LOW, never high. At 15 s against a 900 s block the
# worst-case under-read of one quarter's average is 15/900 = 1.7 % of that
# quarter's own offtake - see test_tariff.py, which computes that bound from
# these two constants rather than quoting it.
CHECKPOINT_S = 15.0
# -- reading states. Three, and never fewer. -------------------------------- #
OBSERVED = "observed" # the store answered and there is a peak
NO_RECORD = "no_record" # the store answered and this month has none yet
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
# --------------------------------------------------------------------------- #
# local time, without a hard dependency on the tz database
# --------------------------------------------------------------------------- #
class _EUTimezone(tzinfo):
"""Europe/Brussels by the EU harmonised rule, for hosts with no tzdata.
⚠️ THIS IS A FALLBACK AND IT IS NOT SILENT. `zoneinfo` is tried first and is
what runs on the add-on's container. The fallback exists because the
alternative - quietly using UTC when tzdata is missing - misfiles every block
in the last two hours of a month into the wrong month's bill, and does it
without a symptom. Encoding the rule (CET, CEST from 01:00 UTC on the last
Sunday of March to 01:00 UTC on the last Sunday of October) is 20 lines and
is checked against zoneinfo, date by date, wherever zoneinfo is available.
"""
# The rule, in both frames. `fromutc` is overridden rather than inherited
# because the base implementation calls back into `utcoffset` with a
# datetime whose tzinfo is self - and the first version of this class
# answered that by calling `astimezone`, which calls `utcoffset`, which
# recursed until the stack ran out. A tzinfo must answer from the value it
# is handed, never by converting it.
@staticmethod
def _last_sunday(year: int, month: int, hour: int) -> datetime:
d = datetime(year, month + 1, 1) - timedelta(days=1)
return (d - timedelta(days=(d.weekday() + 1) % 7)).replace(hour=hour)
def _dst_local(self, naive: datetime) -> bool:
"""From a LOCAL wall clock: 02:00 CET last Sunday March -> 03:00 CEST
last Sunday October. The one ambiguous hour each autumn resolves to
standard time, which cannot move a month boundary."""
y = naive.year
return self._last_sunday(y, 3, 2) <= naive < self._last_sunday(y, 10, 3)
def _dst_utc(self, naive_utc: datetime) -> bool:
"""From UTC: both EU transitions happen at 01:00 UTC, everywhere."""
y = naive_utc.year
return self._last_sunday(y, 3, 1) <= naive_utc < self._last_sunday(y, 10, 1)
def fromutc(self, dt: datetime) -> datetime:
return dt + timedelta(hours=2 if self._dst_utc(dt.replace(tzinfo=None)) else 1)
def utcoffset(self, dt):
if dt is None:
return timedelta(hours=1)
return timedelta(hours=2 if self._dst_local(dt.replace(tzinfo=None)) else 1)
def dst(self, dt):
if dt is None:
return timedelta(0)
return timedelta(hours=1 if self._dst_local(dt.replace(tzinfo=None)) else 0)
def tzname(self, dt):
return "CEST" if dt is not None and self._dst_local(dt.replace(tzinfo=None)) else "CET"
def _billing_tz():
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo("Europe/Brussels")
# Prove it resolves before trusting it: a ZoneInfo built from an empty
# tzdata answers, and answering wrongly is the failure being avoided.
if datetime(2026, 7, 1, tzinfo=timezone.utc).astimezone(tz).hour == 2:
return tz
_LOG.warning("zoneinfo Europe/Brussels resolved but gave the wrong "
"summer offset - falling back to the encoded EU rule")
except Exception as err: # noqa: BLE001 - any failure
_LOG.info("no usable tz database (%s) - using the encoded EU rule", err)
return _EUTimezone()
BILLING_TZ = _billing_tz()
def month_key(when: datetime) -> str:
"""The calendar month a UTC instant is billed in, as 'YYYY-MM' LOCAL time."""
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
local = when.astimezone(BILLING_TZ)
return f"{local.year:04d}-{local.month:02d}"
# --------------------------------------------------------------------------- #
# the per-phase reduction policy - one function, deliberately
# --------------------------------------------------------------------------- #
# §9.1 requires the average be computed per-phase-aware, but does NOT say whether
# the BILLED figure on a three-phase connection is the sum of the phases or the
# worst single phase. §5.2 requires the per-phase data; nothing states the
# reduction. So it is one function with one argument, and correcting it later is
# a one-line change plus a test parameter rather than a change to the
# accumulator. §17's "Three-phase unbalanced load" scenario is the case that
# tells the three apart - on a balanced load all three agree, which is exactly
# why a balanced fixture must not be what this is tested on.
POLICY_NET = "net" # default: the connection's net offtake, what the meter bills
POLICY_SUM = "sum" # sum of the per-phase offtakes (differs when a phase exports)
POLICY_MAX = "max" # the worst single phase
def billed_w(block, policy: str = POLICY_NET) -> float:
"""Reduce one closed block to the single figure the month is billed on."""
pp = getattr(block, "per_phase_offtake_avg_w", None)
if policy == POLICY_NET or not pp:
return float(block.offtake_avg_w)
if policy == POLICY_SUM:
return float(sum(pp))
if policy == POLICY_MAX:
return float(max(pp))
raise ValueError(f"unknown per-phase reduction policy: {policy!r}")
# --------------------------------------------------------------------------- #
# what a read of the store answers
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PeakReading:
"""An answer about a month's peak, or an honest refusal to give one.
⚠️ There is no `peak_w_or(default)` and there must never be one. Every
accessor that lets a caller turn "the database did not answer" into a number
re-creates the defect this type exists to remove. Callers ask `is_observed`
first, and a caller that forgets gets `None` and a TypeError, which is loud.
"""
state: str
month: str
peak_w: float | None = None
quarter_start: datetime | None = None
per_phase_w: tuple | None = None
detail: str = ""
@property
def is_observed(self) -> bool:
return self.state == OBSERVED
@property
def is_unreadable(self) -> bool:
return self.state == NOT_OBSERVED
def explain(self) -> str:
if self.state == OBSERVED:
at = self.quarter_start.isoformat() if self.quarter_start else "?"
return f"{self.month}: peak {self.peak_w:.0f} W, set by the quarter at {at}"
if self.state == NO_RECORD:
return f"{self.month}: no completed quarter recorded yet"
return (f"{self.month}: PEAK UNREADABLE ({self.detail}) - this is NOT a "
f"zero peak and must not be used as headroom")
# --------------------------------------------------------------------------- #
# the store
# --------------------------------------------------------------------------- #
_SCHEMA = """
CREATE TABLE IF NOT EXISTS quarter_blocks (
quarter_start TEXT PRIMARY KEY, -- ISO-8601 UTC, aligned to 900 s
month TEXT NOT NULL, -- 'YYYY-MM', LOCAL calendar month
offtake_avg_w REAL NOT NULL,
per_phase_json TEXT
);
CREATE INDEX IF NOT EXISTS ix_quarter_month ON quarter_blocks(month);
CREATE TABLE IF NOT EXISTS monthly_peak (
month TEXT PRIMARY KEY,
peak_w REAL NOT NULL,
quarter_start TEXT NOT NULL,
per_phase_json TEXT
);
CREATE TABLE IF NOT EXISTS partial_block (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL
);
"""
class PeakStore:
"""SQLite behind /data. Every read answers with a state, never with a default."""
def __init__(self, path: str = "/data/tariff.db"):
self.path = path
self.ok = False
self.last_error: str | None = None
self._db: sqlite3.Connection | None = None
self._open()
def _open(self) -> None:
parent = os.path.dirname(self.path)
# ⚠️ THE DIRECTORY IS NOT CREATED. /data is mounted by the Supervisor and
# always exists on a real install, so `makedirs` here could only ever
# succeed where the durable volume is MISSING - manufacturing a database
# on the container's ephemeral layer that looks durable, reads fine, and
# is thrown away on the next restart. That is a peak store that silently
# forgets, which is worse than one that says it cannot answer.
if parent and not os.path.isdir(parent):
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong. A store that OPENED and then broke is
# the error case, and it is logged as one below.
_LOG.info("no peak store: %s - the monthly peak will read "
"NOT_OBSERVED", self.last_error)
return
try:
self._db = sqlite3.connect(self.path, timeout=5.0,
check_same_thread=False)
# WAL: a power cut mid-write must not leave a torn database that
# reads as "no peak this month" - the exact shape of the defect.
self._db.execute("PRAGMA journal_mode=WAL")
self._db.executescript(_SCHEMA)
self._db.commit()
self.ok = True
self.last_error = None
except sqlite3.Error as err:
self._db = None
self.ok = False
self.last_error = str(err)
# ⚠️ Deliberately NOT raised. §6's fail-open rule: an aggregation
# fault must not stop a controller that is regulating a battery. The
# cost of continuing is a peak that reads NOT_OBSERVED, and every
# reader is built to treat that as "do not use me", not as 0 W.
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
def close(self) -> None:
if self._db is not None:
try:
self._db.close()
finally:
self._db = None
self.ok = False
# -- writing ------------------------------------------------------------
def record_block(self, block, policy: str = POLICY_NET) -> bool:
"""Persist one completed block and fold it into its month's peak.
Both tables move in ONE transaction. A crash between them would leave a
banked quarter that the monthly peak does not know about, and the peak is
the record the money is read off.
"""
if self._db is None:
return False
w = billed_w(block, policy)
start = block.start.astimezone(timezone.utc).isoformat()
month = month_key(block.start)
pp = getattr(block, "per_phase_offtake_avg_w", None)
pp_json = json.dumps(list(pp)) if pp else None
try:
with self._db:
# A block may only ever be RAISED. A restart that reprocesses a
# quarter it had already banked will have LESS coverage than the
# first pass (its first seconds are gone), so an unconditional
# REPLACE would silently lower a recorded figure.
self._db.execute(
"INSERT INTO quarter_blocks(quarter_start, month, offtake_avg_w, per_phase_json)"
" VALUES(?,?,?,?)"
" ON CONFLICT(quarter_start) DO UPDATE SET"
" offtake_avg_w=excluded.offtake_avg_w,"
" per_phase_json=excluded.per_phase_json"
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
(start, month, w, pp_json))
self._db.execute(
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
" VALUES(?,?,?,?)"
" ON CONFLICT(month) DO UPDATE SET"
" peak_w=excluded.peak_w,"
" quarter_start=excluded.quarter_start,"
" per_phase_json=excluded.per_phase_json"
" WHERE excluded.peak_w > monthly_peak.peak_w",
(month, w, start, pp_json))
return True
except sqlite3.Error as err:
self.last_error = str(err)
_LOG.error("could not record quarter %s (%s)", start, err)
return False
# -- reading ------------------------------------------------------------
def peak(self, when: datetime | None = None, month: str | None = None) -> PeakReading:
"""The billed peak for a month. Three possible answers, never a default."""
if month is None:
month = month_key(when or datetime.now(timezone.utc))
if self._db is None:
return PeakReading(NOT_OBSERVED, month,
detail=self.last_error or "store never opened")
try:
row = self._db.execute(
"SELECT peak_w, quarter_start, per_phase_json FROM monthly_peak"
" WHERE month=?", (month,)).fetchone()
except sqlite3.Error as err:
self.last_error = str(err)
return PeakReading(NOT_OBSERVED, month, detail=str(err))
if row is None:
return PeakReading(NO_RECORD, month)
pp = tuple(json.loads(row[2])) if row[2] else None
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
quarter_start=datetime.fromisoformat(row[1]),
per_phase_w=pp)
def recompute_month(self, month: str) -> PeakReading:
"""Re-derive the peak from `quarter_blocks` - the divergence check.
`monthly_peak` is a derived record, so it can be PROVEN equal to the
tier it derives from instead of trusted to be. Read-only.
"""
if self._db is None:
return PeakReading(NOT_OBSERVED, month,
detail=self.last_error or "store never opened")
try:
row = self._db.execute(
"SELECT offtake_avg_w, quarter_start, per_phase_json FROM quarter_blocks"
" WHERE month=? ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1",
(month,)).fetchone()
except sqlite3.Error as err:
self.last_error = str(err)
return PeakReading(NOT_OBSERVED, month, detail=str(err))
if row is None:
return PeakReading(NO_RECORD, month)
pp = tuple(json.loads(row[2])) if row[2] else None
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
quarter_start=datetime.fromisoformat(row[1]),
per_phase_w=pp)
def blocks_in(self, month: str) -> list:
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
counter reports the sample size it actually got."""
if self._db is None:
return []
try:
return [(datetime.fromisoformat(a), float(b)) for a, b in self._db.execute(
"SELECT quarter_start, offtake_avg_w FROM quarter_blocks"
" WHERE month=? ORDER BY quarter_start", (month,))]
except sqlite3.Error as err:
self.last_error = str(err)
return []
# -- the open block's checkpoint ----------------------------------------
def save_partial(self, state: dict) -> bool:
if self._db is None:
return False
try:
with self._db:
self._db.execute(
"INSERT INTO partial_block(id, state_json) VALUES(1,?)"
" ON CONFLICT(id) DO UPDATE SET state_json=excluded.state_json",
(json.dumps(state),))
return True
except (sqlite3.Error, TypeError, ValueError) as err:
self.last_error = str(err)
return False
def load_partial(self) -> dict | None:
if self._db is None:
return None
try:
row = self._db.execute(
"SELECT state_json FROM partial_block WHERE id=1").fetchone()
return json.loads(row[0]) if row else None
except (sqlite3.Error, ValueError) as err:
self.last_error = str(err)
return None
def clear_partial(self) -> None:
# The one DELETE in this module, and it is against the checkpoint - a
# scratch row, not a retention tier. Neither quarter_blocks nor
# monthly_peak is deleted from anywhere in this file.
if self._db is None:
return
try:
with self._db:
self._db.execute("DELETE FROM partial_block WHERE id=1")
except sqlite3.Error as err:
self.last_error = str(err)
# --------------------------------------------------------------------------- #
# the tracker: what main.py holds
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Projection:
"""The open block's end-of-quarter estimate, with the reasons to distrust it.
`elapsed_s` travels WITH the number rather than beside it, because early in a
quarter a projection is a 10-second extrapolation over 900 s and a consumer
that cannot see that will react to noise. `covered_frac` is separately useful:
it is below 1.0 when the meter had a gap, which is the "unreliable quarter"
the functional note asks be marked rather than reported low.
"""
projected_w: float
elapsed_w: float
elapsed_s: float
covered_frac: float
complete: bool
block_start: datetime | None
held_w: float
def explain(self) -> str:
return (f"quarter projects {self.projected_w:.0f} W "
f"({self.elapsed_s:.0f} s elapsed, {self.covered_frac * 100:.0f} % covered"
+ ("" if self.complete else ", INCOMPLETE") + ")")
class PeakTracker:
"""Drives the store from `P1Ingest`. Measures; never commands."""
def __init__(self, store: PeakStore, cap_w: float = 3500.0,
policy: str = POLICY_NET, checkpoint_s: float = CHECKPOINT_S):
self.store = store
self.cap_w = float(cap_w)
self.policy = policy
self.checkpoint_s = float(checkpoint_s)
self.recorded = 0
self.restore_state = "not attempted"
self._seen: set = set()
self._last_ckpt = 0.0
# -- startup ------------------------------------------------------------
def restore(self, averager, now: datetime | None = None) -> str:
"""Put the open block back. Returns which of the three cases happened."""
now = now or datetime.now(timezone.utc)
snap = self.store.load_partial()
if not snap:
self.restore_state = ("fresh: no checkpoint" if self.store.ok
else "fresh: store unreadable")
return self.restore_state
block = snap.get("block")
if block is None:
self.restore_state = "fresh: checkpoint had no open block"
return self.restore_state
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
if block == current:
if not averager.restore(snap):
# A reshaped accumulator (meter_phases changed) is a different
# measurement, not a resumable one. Say so; do not half-resume.
self.store.clear_partial()
self.restore_state = "fresh: checkpoint does not fit this averager"
return self.restore_state
self.restore_state = (
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
return self.restore_state
# The block ended while the add-on was down. Its measured part really
# happened, so bank it at acc/900 rather than discard measured energy.
# It reads LOW because the seconds after the shutdown were never covered,
# and that is the honest figure: coverage a closed block did not get.
closed = _ClosedBlock(
start=datetime.fromtimestamp(block, timezone.utc),
offtake_avg_w=float(snap.get("acc", 0.0)) / QUARTER_S,
per_phase_offtake_avg_w=(tuple(v / QUARTER_S for v in snap["pp_acc"])
if snap.get("pp_acc") and snap.get("last_pp") else None))
banked = self.store.record_block(closed, self.policy)
self.store.clear_partial()
self.restore_state = (
f"closed a stale block from {closed.start.isoformat()} at "
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
+ ("banked" if banked else "NOT banked - store unwritable"))
return self.restore_state
# -- steady state -------------------------------------------------------
def sync(self, ingest, now_mono: float | None = None) -> int:
"""Bank any blocks that closed, and refresh the checkpoint. Returns the
number of blocks newly recorded on this call."""
import time as _t
n = 0
for block in getattr(ingest, "blocks", ()):
key = block.start.timestamp()
if key in self._seen:
continue
if self.store.record_block(block, self.policy):
self._seen.add(key)
self.recorded += 1
n += 1
if n:
# A block just closed, so the checkpoint's block is gone: clearing it
# in the same breath is what stops a crash here from replaying it.
self.store.clear_partial()
self._last_ckpt = 0.0
self.checkpoint(ingest, now_mono or _t.monotonic())
return n
def checkpoint(self, ingest, now_mono: float, force: bool = False) -> bool:
if not force and (now_mono - self._last_ckpt) < self.checkpoint_s:
return False
avg = getattr(ingest, "averager", None)
if avg is None:
return False
self._last_ckpt = now_mono
return self.store.save_partial(avg.snapshot())
# -- reading ------------------------------------------------------------
def peak(self, when: datetime | None = None) -> PeakReading:
return self.store.peak(when)
def headroom_w(self, when: datetime | None = None) -> float | None:
"""Watts left under the soft limit, or None when it cannot be known.
⚠️ `None`, not `self.cap_w`. An unreadable peak returning full headroom
is the disarming default this whole module is shaped against.
"""
r = self.peak(when)
if r.state == NO_RECORD:
return self.cap_w
if not r.is_observed:
return None
return max(0.0, self.cap_w - r.peak_w)
def warning_frac(self, when: datetime | None = None) -> float | None:
"""Peak as a fraction of the soft limit - §9.1's 80 % warning entity."""
r = self.peak(when)
if r.state == NO_RECORD:
return 0.0
if not r.is_observed or self.cap_w <= 0:
return None
return r.peak_w / self.cap_w
@staticmethod
def project(averager, now: datetime) -> Projection:
"""End-of-quarter average if the last sample held for the rest of it.
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
block. `elapsed_w` divides by the seconds actually covered (what the
meter has shown so far); `projected_w` divides by the full 900 s and
credits the held value over what remains. At `elapsed_s == 900` and full
coverage they are equal - which is precisely why nothing may be tested
only at that point.
"""
start = averager.block_start
if start is None:
# ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is
# "there is no quarter". `block_start is None` is the flag that says
# which, and SAFETY-07 grades that flag BEFORE it grades the number -
# see peakguard.blindness(). A consumer that reads only
# `projected_w` here reads 0 W, which is under every ceiling there
# is, on exactly the startup path where nothing else is watching.
return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)
elapsed_wall = max(0.0, (now - start).total_seconds())
elapsed = averager.elapsed_s
acc = averager.partial_ws
remaining = max(0.0, QUARTER_S - elapsed_wall)
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
# fallback, so renaming the attribute would have kept every projection
# green while crediting 0 W held - a projection that reads LOW, which is
# the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4.
# `held_net_w` is now a documented public property on QuarterAverager and
# is read without a default, so the same rename raises AttributeError on
# the first cycle instead of quietly disarming the money guard.
held = max(averager.held_net_w, 0.0)
return Projection(
projected_w=(acc + held * remaining) / QUARTER_S,
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
elapsed_s=elapsed_wall,
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
block_start=start,
# Carried so a consumer can see WHAT was extrapolated, not only the
# extrapolation. It is the signed held value; `projected_w` uses the
# offtake-only form above, and the two differ whenever the site is
# exporting - which is the healthy operating point here.
held_w=averager.held_net_w)
@dataclass(frozen=True)
class _ClosedBlock:
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
`record_block` has one input type; kept private so nothing else makes one."""
start: datetime
offtake_avg_w: float
per_phase_offtake_avg_w: tuple | None
+158
View File
@@ -116,6 +116,21 @@ NOT READY banner already names the problem, (b) does not apply.
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
It is warned about below, not refused.
7. A price configuration that cannot mean what it says. [(a)]
Three cases, all in `_price_problems()`: an unknown `price_provider` id
(refused rather than defaulted to 'off', per the §14 warning box);
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
documented offline fallback into a permanent no-price-data condition
reached by accident. Same shape as 1, 4 and 5 every time - a source the
operator chose, overridden by a layer they cannot see.
⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4
makes no-price-data a supported state, and §9.2 says a large share of the
installed base is on a fixed or static tariff. Refusing to start without a
price feed would lock those sites out of the add-on entirely.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -141,6 +156,8 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from .p1 import is_enabled, validate_meter_options
from .price import PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
_LOG = logging.getLogger("goodwe.config")
@@ -349,6 +366,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
f"or raise max_w to at least {charge_w:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
@@ -382,6 +402,144 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
return problems
def _price_problems(opts: dict) -> list[str]:
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
will be: §11.4 makes no-price-data a first-class supported state (static
profile, provisional plan, no arbitrage), and an add-on that will not start
without a price feed is an add-on that a Belgian fixed-tariff site - a large
share of the installed base, per §9.2 - cannot run at all.
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
two notices), and every refusal is still case (a) from this module's
docstring: a source the operator SET being overridden by a layer they cannot
see. The additions are `static_profile` alongside the Home Assistant price
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
and a `static_profile` that does not tile the day, which would leave hours
with no price at all.
"""
problems: list[str] = []
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
if pid != "off" and pid not in PROVIDERS:
problems.append(
f"price_provider is {pid!r}, which is not a shipped plugin. The "
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
"Refused rather than defaulted, per the §14 warning box - a "
"silent fall back to 'off' would look exactly like a working "
"provider that happens never to find a cheap hour.")
return problems # nothing below can be judged against an unknown id
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
# price_now_entity/price_avg_entity are the original delegation of pricing
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
# the maintenance cheap-window charge floor. They are KEPT - retiring them
# would take a live install's only price signal away - but they may not run
# alongside another price source, because main.py reads exactly one and the
# loser is silent. That is (a), and it is the "silently double up" the PM
# named on 2026-08-30.
#
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
# "would decide nothing", which was true only because nothing consumed the
# series; test_price.py section 12 carried a tripwire that went red the
# moment a consumer landed, with instructions to fix the wording here in the
# same commit. It landed, it went red, and this is that fix. What is true
# NOW, stated exactly and no wider:
#
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
# provider series into a TariffPlan, a cheap-window flag and an
# arbitrage verdict, so the seam is no longer inert.
# * main.py reads the STATIC side of that module every cycle
# (static_status()), which makes static_profile a real writer of `cheap`.
# * main.py does NOT yet call resolve_tariff() on the control tick, and
# that is deliberate: a provider fetch is network I/O and the tick is
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
# no energy decision on its own; a configured STATIC PROFILE does.
#
# Hence two refusals below rather than one, and neither claims more than
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
# first message's middle clause is what changes - not the rule.
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
problems.append(
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
"what main.py reduces to the `cheap` flag the maintenance cycle "
"reads. The provider series IS consumed now - app/static_tariff.py "
"builds a tariff plan and an arbitrage verdict from it - but it is "
"not what main.py acts on, so the supplier you configured would "
"shape no energy decision while the status page named it. Clear "
"price_now_entity and price_avg_entity to use the "
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
"Home Assistant sensors.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
# from the Home Assistant pair when it is set and from the static profile
# otherwise - one flag, first writer wins, and the operator who filled in a
# day/night tariff has no way to see that it lost.
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"static_profile is set AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
"ENTITIES WIN: main.py reads them first and never looks at the "
"static profile, so the day/night tariff you typed would decide "
"nothing and nothing would say so. Clear price_now_entity and "
"price_avg_entity to use static_profile, or clear static_profile "
"to keep the Home Assistant sensors.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to
# serve those are to invent a number or to fail at the moment the clock
# reaches them. §14's warning box - refuse to start on a mismatch rather
# than clamp quietly - is the same instruction one level down.
raw_profile = opts.get("static_profile", "")
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
try:
profile = parse_static_profile(raw_profile)
except ProfileInvalid as e:
profile = None
problems.append(f"static_profile cannot be used: {e}")
if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1:
_LOG.info(
"static_profile carries one distinct price (%.4f EUR/kWh "
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
"off by arithmetic - a flat series has no spread - and the "
"add-on runs self-consumption only, which is §9.2's handling "
"for this product. Peak shaving, reserve management and the "
"maintenance cycle are unaffected.", profile.prices[0])
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
# with none configured has nothing to fall back to when its feed dies. That
# is a real gap worth naming - but refusing to start over it would break
# every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's "
"degradation row is 'no price data -> fall back to the static "
"profile'; with none configured, a feed outage leaves this site "
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
problems.append(
"price_provider is 'manual_csv' but price_csv_path is empty, so "
"the offline fallback has no file to read. Every fetch would "
"return no-price-data with a filesystem error as its reason - "
"which is §11.4 behaviour arrived at by accident rather than by "
"configuration. Point it at the CSV, or set price_provider to "
"'off', which says the same thing on purpose.")
return problems
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+98
View File
@@ -162,9 +162,84 @@ options:
# --- tariff / capacity tariff (all optional) ------------------------------
peak_forecast_entity: ""
peak_cap_w: 3500
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
# nothing except the maintenance cycle's cheap-window charge floor. That is
# all they have ever done. They are kept, because retiring them would take
# the maintenance cycle's only price signal away on a live install before
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
# are the cards that replace it, and neither has landed.
#
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
# one site, one of which silently wins, is exactly the "double up" the PM
# named; validate.py rule 7 refuses that configuration at startup rather than
# letting an operator believe the provider they configured is driving
# something. See app/validate.py.
price_now_entity: ""
price_avg_entity: ""
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
# `off` is the shipped default and means the §11.4 no-price-data row applies:
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
# validates and publishes prices and changes no energy decision.
#
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
# because that has already happened three times.
#
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
# the FRAMEWORK, only for the components the chosen plugin declares its feed
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
# and manual_csv publish a figure that already carries the markup, so setting
# it under those is ignored - and logged as ignored, never applied twice.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
price_csv_path: "/share/goodwe_prices.csv"
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
#
# A JSON object, on one line, whose periods must tile every day type exactly
# once - no gap and no overlap. A gap is REFUSED at startup rather than
# defaulted, because an invented price is one an optimiser would trade on.
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
# add-on derives "fixed" from there being one distinct price, so there is no
# box to tick and none to tick wrongly.
#
# {"periods": [
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
#
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
# a period applies on the day the CLOCK reads, so the weekend row above is
# required, not decorative. See DOCS.md.
#
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
# dynamic providers are assembled onto. price_markup_eur_kwh and
# price_taxes_eur_kwh are NOT added to it.
#
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
# data -> fall back to the static profile"; with this empty there is nothing
# to fall back to and a dead feed leaves the site with no tariff at all.
# validate.py warns about exactly that.
static_profile: ""
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# --- site ------------------------------------------------------------------
estop_fitted: false
log_level: info
@@ -239,5 +314,28 @@ schema:
price_now_entity: str?
price_avg_entity: str?
# ⚠️ The provider list is written out rather than left as `str`, so the
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
# gone below zero (you pay to export at midday in summer), and a schema that
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
estop_fitted: bool
log_level: list(trace|debug|info|warning|error)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+861
View File
@@ -0,0 +1,861 @@
"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
it:
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
tariff - the right window at the right local instant, on both sides of
both 2026 DST transitions, and reaching the one shiftable load the add-on
actually has.
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
AC3 The Belgian scenario suite carries a static-profile regression case.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
check needs an expected number it is computed HERE, by different arithmetic
than app/static_tariff.py uses - the window split is re-derived by summing
over samples with an independent local-hour test, the profile mean by a
second pass, the saving by two routes that must agree.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
two-period tariff has this trap BUILT IN and it is the main hazard here:
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
window" question and the "is it cheap" question agree under almost any
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
specifically because the mon-fri night window MUST NOT cover it.
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
prices "below the mean" and "is the cheapest price" are the same set, so
a two-period profile cannot tell `cheap_now` from `price == min`.
Section 3 therefore runs a THREE-level profile where those two answers
differ, and asserts they differ before grading anything with it.
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
whole shifting question collapses. Asserted as checks in their own
right, first, rather than assumed.
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
where a 24-slot implementation and a correct one agree exactly.
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
import fell in one window grades no split at all; section 6 asserts that
before it reports a saving.
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
price, and zero is exactly the value that makes everything look free. So
`static_plan` with no profile is checked to produce a read with NO
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
"""
import gzip
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
plan_is_provisional,
)
from app.static_tariff import (
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
parse_static_profile, resolve_tariff, static_plan, static_status,
)
from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def raises(fn, exc):
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
'it was refused' and 'it broke on the way to being refused' must not grade
the same."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
def reason(fn):
try:
fn()
except Exception as e: # noqa: BLE001
return str(e)
return ""
class _Raised:
def __init__(self, e):
self.e = e
def __eq__(self, other):
return False
def __hash__(self):
return id(self)
def __bool__(self):
return False
def __repr__(self):
return f"<raised {type(self.e).__name__}: {self.e}>"
def derived(fn):
"""A value a check grades. An exception becomes `_Raised`, which equals
nothing and is falsy - NOT a default. See test_price.py for why."""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
def near(a, b, tol=1e-9):
try:
return abs(float(a) - float(b)) <= tol
except (TypeError, ValueError):
return False
# --------------------------------------------------------------------------- #
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
# --------------------------------------------------------------------------- #
DAY_P = 0.3410 # all-in EUR/kWh, day
NIGHT_P = 0.2570 # all-in EUR/kWh, night
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
TWO_PERIOD = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": NIGHT_P},
]}
FULLY_FIXED = {"periods": [
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
"eur_per_kwh": FIXED_P},
]}
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
# than the mean" and "is the minimum price" are the same set of hours, so a
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
# Here the middle level sits BELOW the mean without being the minimum (the four
# expensive evening hours pull the mean up past it), so those two rules give
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
# anything with it.
THREE_LEVEL = {"periods": [
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
"eur_per_kwh": 0.4800},
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
"eur_per_kwh": 0.2900},
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
"eur_per_kwh": 0.1900},
]}
def opts(profile=None, **over):
o = {"price_provider": "off",
"static_profile": "" if profile is None else json.dumps(profile)}
o.update(over)
return o
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
# any goes red, every shifting assertion below is measuring nothing.
check("day and night prices differ, so 'used the wrong window' is visible",
DAY_P != NIGHT_P)
check("...and neither is zero, so 'dropped the price entirely' is visible",
DAY_P != 0 and NIGHT_P != 0)
check("...and the fixed price is neither of them, so a fixed-tariff result "
"cannot be a day/night result in disguise",
FIXED_P not in (DAY_P, NIGHT_P))
check("...and the day/night spread is not the fixed price, so 'reported the "
"spread where the price was wanted' is visible",
not near(DAY_P - NIGHT_P, FIXED_P))
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the two-period profile puts the right window on the right hour")
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
isinstance(TWO, StaticProfile))
check("...and so does an already-parsed dict (what /data/options.json would "
"carry under a nested schema, and what sim/inject.py hands over)",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
check("...and a bare list of periods, without the wrapper",
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
check("the product is derived from the number of distinct prices, not declared: "
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
check("...and one level -> FIXED, from the same code, with no flag anywhere",
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
# ...and it is really the PRICES, not the row count: three rows, one price.
_three_rows_one_price = parse_static_profile({"periods": [
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
"is on the prices and not on len(periods)",
_three_rows_one_price.product is PriceProduct.FIXED
and len(_three_rows_one_price.periods) == 3)
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
THU = datetime(2026, 1, 15, tzinfo=UTC)
def at_local(day, hh, mm=0):
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
Built by search rather than by adding an offset, so this helper cannot
inherit the same offset bug it is meant to detect.
"""
for minutes in range(-180, 24 * 60 + 180):
u = day + timedelta(minutes=minutes)
loc = brussels(u)
if loc.hour == hh and loc.minute == mm and loc.date() == \
brussels(day + timedelta(hours=12)).date():
return u
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
(12, 0, "day"), (3, 0, "night")]
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
f"21:59 day, 22:00 night {_got}",
all(name == want for (hh, mm, want), (_, __, name)
in zip(_probes, _got)))
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
"night, which a [22:00,24:00) reading would call uncovered",
TWO.at(at_local(THU, 3)).name == "night")
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
# morning - a period applies on the day the CLOCK reads. The weekend row is what
# covers Saturday 03:00, and it is a DIFFERENT Period object.
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
_sat3 = TWO.at(at_local(SAT, 3))
_thu3 = TWO.at(at_local(THU, 3))
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
"window bleeding over - same name and price, different period",
_sat3.name == _thu3.name and _sat3 is not _thu3
and 5 in _sat3.days and 5 not in _thu3.days)
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
"runs all day - a weekday-shaped lookup would call it day",
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
check("...while Thursday 12:00 is day-priced, so the two are really different "
"answers and not one answer twice",
near(TWO.price_at(at_local(THU, 12)), DAY_P))
# next_change: the shifting primitive - "how long until the cheap window".
_nc = TWO.next_change(at_local(THU, 12))
check(f"next_change from Thursday 12:00 is the 22:00 flip "
f"{None if _nc is None else brussels(_nc).isoformat()}",
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
_nc2 = TWO.next_change(at_local(SAT, 12))
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
"is one window - a scan that stopped at midnight would say 24 h",
_nc2 is not None and brussels(_nc2).hour == 7
and brussels(_nc2).weekday() == 0)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
FIX = parse_static_profile(FULLY_FIXED)
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
# local, and `price_at` would correctly raise for it. That raise is the right
# behaviour (an hour outside the series is an hour with no price) but it is not
# what these sections are grading.
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
check("the fixed plan really produced a series (so the refusal below is about "
"the tariff, not about a failed read)",
p_fixed.has_prices and len(p_fixed.read.points) == 72)
_spread_fixed = derived(p_fixed.spread_eur_kwh)
_spread_two = derived(p_two.spread_eur_kwh)
check(f"...and its spread is exactly zero {_spread_fixed}",
near(_spread_fixed, 0.0))
check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False)
_v_two = p_two.arbitrage(peak_guard_active=False)
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
"refusal is discriminating rather than universal",
_v_two.kind == "allowed")
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
# price all day must be refused for the same reason, or the rule is a label
# check wearing arithmetic's clothes.
_flat_dayahead = TariffPlan(
PriceRead.series(
[PricePoint(start=brussels(THU + timedelta(hours=i)),
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
"dearest hour - the whole input set for a trade",
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
and near(_v_two.dearest.eur_per_kwh, DAY_P))
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
"Absence rather than answering 0.0, which is the number that would make "
"every hour look equally good",
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
and raises(lambda: _v_fixed.cheapest, Absence))
check("...and it is not iterable and has no .get(), the other two routes from "
"a refusal to an empty, harmless-looking loop",
raises(lambda: list(_v_fixed), Absence)
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
and raises(lambda: p_two.arbitrage(False), TypeError))
check("...nor answered with a truthy 'unknown'",
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
# --------------------------------------------------------------------------- #
print()
print("3. the cheap-window rule, on a tariff where it is not degenerate")
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
# minimum" select the SAME hours, so a two-period profile cannot tell
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
# level sits between the two, and the check below PROVES the two rules disagree
# before anything is graded with it.
THREE = parse_static_profile(THREE_LEVEL)
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
_mean3 = p3.mean_eur_kwh()
_min3 = min(p.eur_per_kwh for p in p3.read.points)
_day3 = 0.2900
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
f"strictly below the middle level {_day3:.4f}, which is strictly below "
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
f"DIFFERENT sets of hours and this fixture can tell them apart",
_min3 < _day3 < _mean3)
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
"the mean without being the cheapest price of the day, which a "
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
check("...and the middle level really is not the minimum, so that check graded "
"the difference rather than restating it",
not near(p3.price_at(at_local(THU, 12)), _min3))
check("...and calls the 00:00-07:00 minimum cheap",
p3.cheap_now(at_local(THU, 3)) is True)
check("...and the 17:00-21:00 peak not cheap",
p3.cheap_now(at_local(THU, 18)) is False)
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
# the battery up from the grid at any hour - the opposite of self-consumption
# only.
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
f"which is what `<` buys and `<=` would destroy",
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
check("...and the price really is defined at every one of those hours, so the "
"'no cheap window' answer is a priced no and not a missing read",
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
# ...and on the two-period tariff the cheap hours are exactly the night ones.
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
f"two ways {sorted(_cheap_h)}",
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
# cheap or dear depending on when the question was put.
FRI = datetime(2026, 1, 16, tzinfo=UTC)
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
).mean_eur_kwh()
check(f"the 7-day mean is stable across the day it is asked on "
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
f"property of the tariff and not of the clock",
near(_mean_thu, _mean_fri, 5e-4))
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
f"{_mean48_fri:.4f}), so the seven days are buying something real",
not near(_mean48_thu, _mean48_fri, 5e-4))
# --------------------------------------------------------------------------- #
print()
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
# which is itself worth knowing, because the wrap-around weekday row is not what
# is under test here.
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
def local_day_points(profile, day_utc):
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
target = brussels(day_utc + timedelta(hours=12)).date()
pts = profile.series(day_utc - timedelta(hours=6),
day_utc + timedelta(hours=30), provisional=False)
return [p for p in pts if p.start.date() == target]
_spring = derived(lambda: local_day_points(TWO, SPRING))
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
check(f"the spring-forward local day emits 23 points, not 24 "
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
not isinstance(_spring, _Raised) and len(_spring) == 23)
check(f"the autumn-back local day emits 25 points, not 24 "
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
check("...and the two 02:00 points in October are DIFFERENT instants at "
"DIFFERENT offsets, which a naive local-hour key would collide",
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
check("...and every emitted start is strictly increasing in UTC, which is what "
"price.py's boundary check demands of a fetched feed too",
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
for a, b in zip(_autumn, _autumn[1:])))
# A weekday profile across a transition, so the wrap-around path sees one too.
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
"and not a systematic miscount", len(_wrapdays) == 24)
# --------------------------------------------------------------------------- #
print()
print("5. a failed read is not an observation, and a gap is not a price")
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
check("no static_profile produces a NO-DATA read, not an empty series and not "
"a zero one", not _none.has_prices)
check("...and reading a price off it RAISES rather than answering 0.0 - the "
"value that would make every hour look free",
raises(lambda: _none.price_at(THU), Absence)
and raises(_none.mean_eur_kwh, Absence)
and raises(_none.spread_eur_kwh, Absence))
check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
_gap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
check("...and names the uncovered range so the operator can fix it",
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
_overlap = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
"eur_per_kwh": NIGHT_P}]}
check("...and an overlap is refused too - two prices for one hour means one "
"wins on list order",
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
_noweekend = {"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": DAY_P},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": NIGHT_P}]}
check("a weekday-only profile is refused because Saturday and Sunday have no "
"price - the mistake a wrap-around window invites",
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
"discriminates rather than refusing everything",
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
_bad = [
("not JSON at all", "day 07:00-22:00 0.34"),
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh=30.7)]}),
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
eur_per_kwh="cheap")]}),
("a missing days selector", {"periods": [
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
days="mon-frd")]}),
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
**{"from": "7.5"})]}),
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
("an empty periods list", {"periods": []}),
]
for label, value in _bad:
payload = value if isinstance(value, str) else json.dumps(value)
check(f"refused: {label}",
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
check("...and every one of those refusals says something an operator can act "
"on, rather than just failing",
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
parse_static_profile(p))) > 40 for _, v in _bad))
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
("whitespace", " ")):
check(f"...while {label!r} means 'no profile' rather than an error",
parse_static_profile(value) is None)
# --------------------------------------------------------------------------- #
print()
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
# six scenarios; none of them is a static-profile case, which is why AC3 asks
# for a seventh. What exists as DATA is two days of this house's real P1 net
# power, captured from the live meter and committed as
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
#
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
# happen to be one weekday and one weekend day, which is exactly the pair a
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
# section does not claim to be one; the numbers it prints are the numbers those
# two days actually produce.
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
print(f" replay fixtures found: {len(REPLAY)} "
f"({', '.join(p.name for p in REPLAY) or 'none'})")
check("the committed replay fixtures are present - a regression fixture CI "
"cannot see is not a regression fixture", len(REPLAY) >= 2)
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
def replay_import(path):
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
- rather than by asking the module under test. The two must agree; that
agreement is the check.
"""
per_window = {}
total = 0.0
first = last = None
prev = None
days = set()
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if first is None:
first = t
last = t
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
kwh = prev[1] * dt / 3_600_000.0
loc = brussels(prev[0])
days.add(loc.weekday())
window = ("day" if loc.weekday() < 5
and 7 <= loc.hour < 22 else "night")
per_window[window] = per_window.get(window, 0.0) + kwh
total += kwh
prev = (t, w)
span = (last - first).total_seconds() / 3600.0 if first else 0.0
return per_window, total, span, days
def cost_under(path, profile):
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
total = 0.0
prev = None
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
w = float(row["w"])
if prev is not None:
dt = (t - prev[0]).total_seconds()
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
prev = (t, w)
return total
replay_days = 0.0
graded = 0
for path in REPLAY:
windows, total_kwh, span_h, weekdays = replay_import(path)
replay_days += span_h / 24.0
day_kwh = windows.get("day", 0.0)
night_kwh = windows.get("night", 0.0)
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
print(f" {path.name}: {span_h:.1f} h, {kind} "
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
check(f"{path.name}: the two routes to the total agree",
near(day_kwh + night_kwh, total_kwh, 1e-9))
# The module's own pricing must reproduce the independently-split volumes.
c_two = cost_under(path, TWO)
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
check(f"{path.name}: the module prices the real day at "
f"EUR {c_two:.4f}, which equals the independently-split "
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
c_fix = cost_under(path, FIX)
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
# whose entire import landed in one window grades nothing: the saving would
# be 0 under a correct implementation AND under one that ignored the clock.
if day_kwh > 0 and night_kwh > 0:
graded += 1
saving = c_two - total_kwh * NIGHT_P
check(f"{path.name}: this day DOES import in both windows "
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
f"something", True)
check(f"{path.name}: shifting every day-window kWh into the night "
f"window saves EUR {saving:.4f}, which is exactly "
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
f"routes to one number",
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
# AC2 on the same real data: under a fixed tariff there is nothing to
# shift, and that is arithmetic rather than a special case.
check(f"{path.name}: under the FIXED tariff the identical shift saves "
f"exactly nothing, so self-consumption-only is the right "
f"behaviour for that product",
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
else:
print(f" {path.name}: import fell in ONE window only "
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
f"day-typing witness, not a shifting one, and no saving is "
f"claimed from it")
print()
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
f"windows and therefore grading the split.")
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
f"ONE - but the suite holds two captured days, not a season, so nothing "
f"here is a claim about annual savings. §17's six named scenarios do not "
f"include a static-profile case; this is the seventh, and TEST-01 should "
f"say so.")
check("the replay produced a measurable split over a stated sample size, "
"rather than a criterion quietly redefined to fit the data",
graded >= 1 and replay_days < 30)
check("...and BOTH day types are present in the fixtures, which is what makes "
"the weekend row testable on real data at all",
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
for p in REPLAY}) == 2)
# --------------------------------------------------------------------------- #
print()
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
async def _dead(url, timeout_s):
raise OSError("the supplier's feed is down")
def run(coro):
import asyncio
return asyncio.run(coro)
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed falls back to the static profile rather than to nothing",
_fb.origin == "fallback" and _fb.has_prices)
check("...with the SAME prices the configured-product path produces, because "
"it is one generator and not a second implementation",
[p.eur_per_kwh for p in _fb.read.points]
== [p.eur_per_kwh for p in static_plan(
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
price_timeout_s=1),
THU, THU + timedelta(days=1), transport=_dead))
check("a dead feed with NO static_profile has nothing to fall back to, and "
"says that rather than inventing a tariff",
_fb_none.origin == "none" and not _fb_none.has_prices)
# A live feed still wins over the profile.
_body = json.dumps({"data": [
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
for i in range(24)]}).encode()
async def _live(url, timeout_s):
return _body
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
transport=_live))
check("a live feed is used in preference to the static profile",
_live_plan.origin == "provider"
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh,
0.023, 1e-9))
# --------------------------------------------------------------------------- #
print()
print("8. the startup gate, and CFG-03")
def vbase(**over):
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off",
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
o.update(over)
return o
def why(problems, needle):
return any(needle in p for p in problems)
check("a good static_profile starts",
validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000),
"static_profile cannot be used"))
check("...and the refusal names the uncovered range",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000), "22:00-23:00"))
check("static_profile alongside the Home Assistant price entities is refused - "
"main.py reads one of them and the loser is silent",
why(validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD),
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000), "HOME ASSISTANT"))
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
"away from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
-5000, 5000) == [])
check("...and an empty static_profile is never refused: §9.2 puts a large share "
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
"supported state",
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
and validate_startup_config(vbase(), -5000, 5000) == [])
check("...and a fully-fixed profile starts, since a one-price site is a "
"supported product and not a misconfiguration",
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
-5000, 5000) == [])
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
# created and pinned to config.yaml - rather than to a second defaults dict in
# the new module. That mirror is enforced by test_price.py section 1, which
# reads config.yaml; this check is the other half of the claim: the new module
# must carry NO defaults dict of its own.
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
check("the new module holds no option-defaults dict of its own, so this ticket "
"added ZERO new places an option default lives (CFG-03)",
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
check("...and it reads the option through price.price_option(), the framework's "
"single read path, rather than through opts.get(key, <a number>)",
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
"either - it calls static_status(o, now), and the option is read once, "
"inside the framework",
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+910
View File
@@ -0,0 +1,910 @@
"""Runnable check for TARIFF-01, the capacity-tariff measurement.
python3 test_tariff.py
Ten parts. What each is for, and - where it matters - what it is deliberately
NOT allowed to be tested at:
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
segment. The fixture is chosen to be non-degenerate on three axes at once:
irregular sample spacing, a sample that STRADDLES the :15 boundary, and a
gap longer than `max_hold_s` so the block's coverage is short of 900 s.
2. THE 900 s DEGENERACY, named and then avoided. `acc/elapsed`, `acc/900` and
the end-of-quarter projection are THREE DIFFERENT FORMULAS that all agree
at exactly full coverage of a complete block. A check run only there proves
nothing about any of them, so part 2 first shows them agreeing at that
point (so the trap is documented rather than asserted around), then shows
part 1's fixture separating all three - and every later part uses fixtures
that are away from it.
3. RESTART MID-BLOCK, through the real `QuarterAverager.snapshot()`/`restore()`
and a real SQLite file: a restarted run must land on the same closed block
as an uninterrupted one, and a run that DOES NOT restore must land lower.
Without that second half, "restore works" is satisfied by a fixture where
restoring changes nothing.
4. MONTH ROLLOVER, in LOCAL time, including a restart that straddles it.
5. AN UNREADABLE STORE IS NOT A ZERO PEAK - the acceptance argument. Three
states, three different answers, and no accessor that turns "the database
did not answer" into a number.
6. THE TIMEZONE FALLBACK, graded against `zoneinfo` day by day.
7. THE PER-PHASE REDUCTION on an UNBALANCED load. §17's three-phase case. On a
balanced load net/sum/max all coincide, which is why a balanced fixture is
worth nothing here.
8. `monthly_peak` vs a recompute from `quarter_blocks` - the derived record
proven equal to the tier it derives from, and proven never to be lowered.
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
10. THE WIRING, through the real `Controller` and its real status page.
------------------------------------------------------------------------------
⚠️ AC 3 IS NOT MET, AND PART 9 DOES NOT PRETEND OTHERWISE.
AC 3 asks for "the monthly peak held under the soft limit in >= 95 % of
intervals over a 30-day simulation replay". THERE IS NO 30-DAY DATASET. The
infra repo's `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 on 2026-08-25 because they were about to age out of HA's recorder,
and nothing longer was ever captured. `sim/scenarios/README.md` lists the whole
inventory; the rest is RS485 bus frames, OTA witness logs and a 578-row rollback
poll, none of which carries grid power over calendar time.
So part 9 replays the two days that exist, reports the percentage over them, and
states the sample size in days next to it. It is a MEASUREMENT, not a verdict:
nothing in it exits non-zero on the 95 % figure, because a 2-day run cannot pass
or fail a 30-day criterion and a check that claimed it could would be the more
expensive of the two mistakes. Both days are also *uncontrolled* - the add-on
was not shaving peaks on either - so the figure they give is the BASELINE the
guard has to improve on, which is the useful thing a 2-day replay can say.
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
infra repo is private, CI's token is minted for this repository only, and a
regression fixture that CI cannot see is not a regression fixture.
Closing AC 3 needs 30 days of capture. That is TEST-01's replay harness, and it
cannot start before somebody starts recording.
"""
import asyncio
import gzip
import json
import shutil
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
month_key,
)
fails = []
HERE = Path(__file__).resolve().parent
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
# The live install's numbers: NOTES.md and freeze.py both record max_w 5000
# there. `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3
# grades against - read out of the shipped file in part 10 rather than typed.
LIVE_MAX_W = 5000.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def near(a, b, tol=1e-6):
return a is not None and b is not None and abs(a - b) <= tol
def sample(t: datetime, net_w: float, phases=1, phase_net=None):
"""One P1 telegram, through the SHIPPED derivation.
`split_signed` and `make_sample` are what the real transports call, so the
sign convention under test is the one that ships rather than a local
re-derivation of it.
"""
imp, exp = split_signed(net_w)
pi = pe = None
if phase_net is not None:
pairs = [split_signed(v) for v in phase_net]
pi = [a for a, _ in pairs]
pe = [b for _, b in pairs]
return make_sample("ha_signed", imp, exp, phases=phases,
phase_import_w=pi, phase_export_w=pe,
ingest_ts=t, ingest_mono=t.timestamp())
def fresh_store(name: str) -> PeakStore:
return PeakStore(str(TMP / f"{name}.db"))
# B is a clock-aligned quarter boundary: proved, not assumed, because every
# other number in part 1 rests on it.
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
# ==============================================================================
print("1. the block arithmetic, on a worked example")
check(f"the fixture's start {B.isoformat()} really is 900 s aligned",
int(B.timestamp()) % QUARTER_S == 0)
# (t seconds after B, net W). Deliberately irregular, with an export, a gap
# longer than max_hold_s, and a last sample that straddles the boundary.
MAX_HOLD_S = 30.0
FIXTURE = [(0, 1000.0), (20, 2000.0), (30, -500.0), (50, 3000.0),
(250, 4000.0), (880, 1200.0), (1000, 900.0)]
# The worked example, written as the segments a person would compute by hand:
# each HELD value and how many seconds of the FIRST block it is evidence for.
# Two rules do all the work: a sample is held forward until the next one, capped
# at MAX_HOLD_S; and export is 0 W of offtake, never a negative contribution.
SEGMENTS = [
(1000.0, 20), # 0 -> 20, a normal telegram interval, held in full
(2000.0, 10), # 20 -> 30, likewise
(0.0, 20), # 30 -> 50: -500 W is EXPORT, so 0 W of offtake for 20 s
(3000.0, 30), # 50 -> 250 is a 200 s GAP; only the first 30 s is evidence
(4000.0, 30), # 250 -> 880 is a 630 s gap; again only 30 s is evidence
(1200.0, 20), # 880 -> 900: the STRADDLE's first half, up to the boundary
]
# The straddle's second half: the same held 1200 W, from the boundary until its
# hold expires at 880 + 30. It must be credited to the NEXT block, not to this
# one and not to neither.
SPLIT_TAIL_W, SPLIT_TAIL_S = 1200.0, 10
EXP_WS = sum(w * s for w, s in SEGMENTS)
EXP_COVERED_S = sum(s for _, s in SEGMENTS)
EXP_BLOCK_AVG = EXP_WS / QUARTER_S # what the utility bills
EXP_ELAPSED_AVG = EXP_WS / EXP_COVERED_S # what a /elapsed divisor gives
NAIVE_MEAN = sum(max(w, 0.0) for _, w in FIXTURE[:-1]) / (len(FIXTURE) - 1)
avg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
closed = []
for dt_s, w in FIXTURE:
closed += avg.add(sample(B + timedelta(seconds=dt_s), w))
print(f" {len(SEGMENTS)} held segments -> {EXP_WS:,.0f} Ws over "
f"{EXP_COVERED_S:.0f} s of the {QUARTER_S} s block")
check(f"exactly one block closed", len(closed) == 1)
check(f"...starting at the fixture's own boundary {B.isoformat()}",
bool(closed) and closed[0].start == B)
check(f"...averaging {EXP_BLOCK_AVG:.2f} W - the worked example's "
f"{EXP_WS:,.0f} Ws over the FULL {QUARTER_S} s",
bool(closed) and near(closed[0].offtake_avg_w, EXP_BLOCK_AVG, 1e-9))
# The three ways of being wrong, each of which produces a plausible number.
print(f" the same data read three other ways: /elapsed = {EXP_ELAPSED_AVG:.2f} W, "
f"naive mean of samples = {NAIVE_MEAN:.2f} W, "
f"billed = {EXP_BLOCK_AVG:.2f} W")
check("...and /elapsed is NOT the billed figure - a gap must drag the average "
"down, not be averaged away",
abs(EXP_ELAPSED_AVG - EXP_BLOCK_AVG) > 1.0)
check("...and a naive mean over samples is NOT the billed figure either - it "
"weights a burst of fast telegrams like a slow one",
abs(NAIVE_MEAN - EXP_BLOCK_AVG) > 1.0)
# The straddle: the 4000 W sample at t=400 is held ACROSS 900. Its evidence
# expires at 430, so the second block gets none of it - and the second block
# must still have been OPENED at 900, not at 1000.
check(f"the block after the straddle opens at the boundary, not at the sample",
avg.block_start == B + timedelta(seconds=QUARTER_S))
print(f" the straddling sample is SPLIT at the boundary: "
f"{SEGMENTS[-1][0]:.0f} W x {SEGMENTS[-1][1]} s into the first block, "
f"x {SPLIT_TAIL_S} s into the second")
check(f"...and the second block is credited the tail "
f"({SPLIT_TAIL_W * SPLIT_TAIL_S:,.0f} Ws), so a sample spanning :15 is "
f"attributed to BOTH blocks in proportion and wholly to neither",
near(avg.partial_ws, SPLIT_TAIL_W * SPLIT_TAIL_S, 1e-9))
# ==============================================================================
print()
print("2. the 900 s degeneracy - where three different formulas agree")
# A complete block at a constant power, fully covered. Here acc/elapsed,
# acc/900 and the projection are ALL the same number. Anything checked only
# here is checked against nothing.
HELD_W = 2400.0
deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
deg_closed = []
for i in range(0, QUARTER_S + 1, 5):
deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W))
d_block = deg_closed[0].offtake_avg_w
deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
LAST_S = QUARTER_S - 5
for i in range(0, LAST_S + 1, 5):
deg2.add(sample(B + timedelta(seconds=i), HELD_W))
d_partial = deg2.offtake_avg_w
# Graded AT the last sample: full coverage behind it, a held value in front.
d_proj = PeakTracker.project(deg2, B + timedelta(seconds=LAST_S)).projected_w
print(f" at full coverage of a whole block: acc/900 = {d_block:.2f} W, "
f"acc/elapsed = {d_partial:.2f} W, projection = {d_proj:.2f} W")
check("all three formulas coincide at exactly 900 s of full coverage - so a "
"check placed only there distinguishes none of them",
near(d_block, HELD_W, 1e-6) and near(d_partial, HELD_W, 1e-6)
and near(d_proj, HELD_W, 1e-6))
# Away from it, on part 1's fixture at a point mid-block, they must separate.
mid = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
for dt_s, w in FIXTURE[:-1]:
mid.add(sample(B + timedelta(seconds=dt_s), w))
NOW = B + timedelta(seconds=500)
p = PeakTracker.project(mid, NOW)
print(f" at t=500 s on part 1's fixture: acc/elapsed = {p.elapsed_w:.2f} W, "
f"projection = {p.projected_w:.2f} W, coverage = {p.covered_frac * 100:.1f} %")
check("mid-block, with a gap in it, the elapsed average and the projection are "
"DIFFERENT numbers", abs(p.elapsed_w - p.projected_w) > 1.0)
check("...and the projection carries elapsed_s, so a consumer can see it is an "
f"extrapolation over {QUARTER_S - p.elapsed_s:.0f} s it has not seen",
near(p.elapsed_s, 500.0, 1e-6))
check("...and a gap shows up as coverage below 1.0 rather than as a confident "
"low number", p.covered_frac < 1.0 and not p.complete)
# ==============================================================================
print()
print("3. restart mid-block: the same block, or a lower one, never a lost one")
RUN_W = 2000.0
STEP_S = 1
RESTART_AT_S = 600 # minute 10 of the quarter - the functional note's case
def feed(avg, t0_s, t1_s, w=RUN_W):
out = []
for i in range(t0_s, t1_s + 1, STEP_S):
out += avg.add(sample(B + timedelta(seconds=i), w))
return out
# (a) the control: no restart at all.
ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
ctrl_closed = feed(ctrl, 0, QUARTER_S)
CTRL_W = ctrl_closed[0].offtake_avg_w
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
st = fresh_store("restart")
tr = PeakTracker(st, cap_w=LIVE_MAX_W)
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
feed(a1, 0, RESTART_AT_S)
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
a2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) # a brand-new process
restored = tr.restore(a2, B + timedelta(seconds=RESTART_AT_S + 1))
print(f" restore() said: {restored}")
check("...and restore() reports RESUMING rather than starting fresh",
restored.startswith("resumed"))
r_closed = feed(a2, RESTART_AT_S + STEP_S, QUARTER_S)
RESTART_W = r_closed[0].offtake_avg_w if r_closed else None
# (c) the mutation guard: the SAME restart with no restore.
a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S)
NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None
print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | "
f"restart with no restore {NORESTORE_W:.2f} W")
check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an "
f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6))
check("...and without the restore it lands LOWER - so 'the restore works' is "
"not satisfied by a fixture where restoring changes nothing",
NORESTORE_W is not None and NORESTORE_W < CTRL_W - 1.0)
# Derived, not asserted against a literal: the lost part is exactly the seconds
# before the restart, at the power that was flowing.
DROPPED_S = RESTART_AT_S + STEP_S # the resumed feed starts one step later
check(f"...and it is lower by exactly the {DROPPED_S} s it dropped "
f"({RUN_W * DROPPED_S / QUARTER_S:.2f} W)",
near(CTRL_W - NORESTORE_W, RUN_W * DROPPED_S / QUARTER_S, 1e-6))
# (d) a LONG outage: restored, but the meter was away past max_hold_s. The
# uncovered seconds must drag the block DOWN, not be held forward.
OUTAGE_S = 200
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
st4 = fresh_store("outage")
st4.save_partial(a1.snapshot())
PeakTracker(st4, cap_w=LIVE_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: "
f"{o_closed[0].offtake_avg_w:.2f} W")
check(f"...reads lower than the uninterrupted block by exactly the uncovered "
f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is "
f"never held forward across the whole outage",
near(CTRL_W - o_closed[0].offtake_avg_w,
RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6))
# (e) double counting is answered by the primary key, not by care.
st5 = fresh_store("dedupe")
st5.record_block(ctrl_closed[0])
st5.record_block(ctrl_closed[0])
st5.record_block(ctrl_closed[0])
rows = st5.blocks_in(month_key(B))
check("banking the same quarter three times leaves ONE row - quarter_start is "
"the primary key, so a replay or a restart cannot double-count",
len(rows) == 1 and near(rows[0][1], CTRL_W, 1e-9))
# ...and a later pass with LESS coverage may not lower a banked figure.
from app.tariff import _ClosedBlock as _CB
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None))
after = st5.blocks_in(month_key(B))
check(f"...and re-banking the same quarter at half the figure leaves it at "
f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a "
f"quarter that was already measured in full",
len(after) == 1 and near(after[0][1], CTRL_W, 1e-9))
# (f) the checkpoint's own staleness bound, derived from the constant.
print(f" CHECKPOINT_S = {CHECKPOINT_S:.0f} s, so a crash can lose at most "
f"{CHECKPOINT_S / QUARTER_S * 100:.1f} % of one quarter's average")
check("the checkpoint bound is small enough that its worst case is under 2 % "
"of a quarter", CHECKPOINT_S / QUARTER_S < 0.02)
# ==============================================================================
print()
print("4. month rollover, in LOCAL time, and across a restart that straddles it")
# CEST is UTC+2, so the last two hours of 31 August UTC are already September.
AUG_LATE = datetime(2026, 8, 31, 21, 45, tzinfo=timezone.utc)
SEP_EARLY = datetime(2026, 8, 31, 22, 15, tzinfo=timezone.utc)
print(f" {AUG_LATE.isoformat()} is local "
f"{AUG_LATE.astimezone(BILLING_TZ).isoformat()} -> {month_key(AUG_LATE)}")
print(f" {SEP_EARLY.isoformat()} is local "
f"{SEP_EARLY.astimezone(BILLING_TZ).isoformat()} -> {month_key(SEP_EARLY)}")
check("a quarter starting 21:45 UTC on 31 Aug bills in AUGUST",
month_key(AUG_LATE) == "2026-08")
check("a quarter starting 22:15 UTC on 31 Aug bills in SEPTEMBER - filing it "
"under August would move a whole month's bill",
month_key(SEP_EARLY) == "2026-09")
check("...and the two are genuinely on opposite sides of the boundary, not "
"the same answer twice", month_key(AUG_LATE) != month_key(SEP_EARLY))
# Winter, where the offset is +1 and the boundary sits an hour later.
JAN_LATE = datetime(2026, 1, 31, 22, 45, tzinfo=timezone.utc)
check("in WINTER the same trap is an hour later: 22:45 UTC on 31 Jan is still "
"January, and 23:15 UTC is February",
month_key(JAN_LATE) == "2026-01"
and month_key(JAN_LATE + timedelta(minutes=30)) == "2026-02")
st6 = fresh_store("months")
AUG_PEAK_W, SEP_PEAK_W = 4998.0, 900.0
st6.record_block(_CB(AUG_LATE, AUG_PEAK_W, None))
st6.record_block(_CB(SEP_EARLY, SEP_PEAK_W, None))
r_aug, r_sep = st6.peak(month="2026-08"), st6.peak(month="2026-09")
print(f" {r_aug.explain()}")
print(f" {r_sep.explain()}")
check(f"August's peak is the {AUG_PEAK_W:.0f} W quarter (specs.md:583's own "
f"measured case) and September's is not",
r_aug.is_observed and near(r_aug.peak_w, AUG_PEAK_W)
and r_sep.is_observed and near(r_sep.peak_w, SEP_PEAK_W))
check("...so a new month starts from its OWN quarters, not from the previous "
"month's peak", r_sep.peak_w < r_aug.peak_w)
# The restart that straddles the boundary: a checkpoint whose open block is in
# August, restored when `now` is already September.
st7 = fresh_store("straddle")
tr7 = PeakTracker(st7, cap_w=LIVE_MAX_W)
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
for i in range(0, 400, STEP_S):
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
st7.save_partial(straddler.snapshot())
after_boot = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
said = tr7.restore(after_boot, SEP_EARLY + timedelta(minutes=3))
print(f" restore() across the boundary said: {said}")
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
check("a restart that straddles the month boundary BANKS the dead August "
"quarter rather than dropping measured energy",
said.startswith("closed a stale block") and aug7.is_observed)
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
check("...and September reads NO_RECORD, which is a different answer from "
"August's peak AND from a zero",
sep7.state == NO_RECORD and sep7.peak_w is None)
check("...and the checkpoint is cleared, so a second boot cannot re-bank it",
st7.load_partial() is None)
# ==============================================================================
print()
print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
# Three stores, three states, and the three must not answer alike.
good = fresh_store("states")
good_tr = PeakTracker(good, cap_w=LIVE_MAX_W)
empty_r = good_tr.peak(B)
gone = fresh_store("gone")
gone_tr = PeakTracker(gone, cap_w=LIVE_MAX_W)
gone.record_block(_CB(B, 3000.0, None))
gone.close() # the database goes away under a live reader
gone_r = gone_tr.peak(B)
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
never_tr = PeakTracker(never, cap_w=LIVE_MAX_W)
never_r = never_tr.peak(B)
# A store that is OPEN and whose query FAILS is a third code path - a closed
# handle short-circuits before the SQL ever runs, so without this the
# `except sqlite3.Error` arm of peak() is never executed by anything.
broken = fresh_store("broken-table")
broken.record_block(_CB(B, 3000.0, None))
broken._db.execute("DROP TABLE monthly_peak")
broken._db.commit()
broken_r = PeakTracker(broken, cap_w=LIVE_MAX_W).peak(B)
for label, r in (("fresh store, no quarter yet", empty_r),
("store that broke under a live reader", gone_r),
("store that never opened", never_r),
("store open, query failed", broken_r)):
print(f" {label:38s} -> {r.state:13s} {r.explain()}")
check("a fresh store with no quarter yet says NO_RECORD - a normal state on "
"the 1st of the month", empty_r.state == NO_RECORD)
check("a store that broke says NOT_OBSERVED", gone_r.state == NOT_OBSERVED)
check("a store that never opened says NOT_OBSERVED", never_r.state == NOT_OBSERVED)
check("a store whose QUERY fails says NOT_OBSERVED too - a different code path "
"from a closed handle, and the one a half-corrupt database takes",
broken_r.state == NOT_OBSERVED and broken_r.peak_w is None)
# ...and losing the DERIVED record does not lose the peak, which is the whole
# argument for keeping monthly_peak recomputable from the 15-min tier.
_rec = broken.recompute_month(month_key(B))
print(f" ...but with the 15-min tier intact, recompute_month() still "
f"answers: {_rec.explain()}")
check("...while recompute_month() STILL answers from the surviving 15-min "
"tier - the permanent record is derived, so losing it is recoverable",
_rec.is_observed and near(_rec.peak_w, 3000.0))
broken._db.execute("DROP TABLE quarter_blocks")
broken._db.commit()
check("...and once BOTH tiers are gone it says NOT_OBSERVED rather than "
"raising into the control loop",
broken.recompute_month(month_key(B)).state == NOT_OBSERVED)
check("...and NO_RECORD and NOT_OBSERVED are DIFFERENT states, not two "
"spellings of the same one", empty_r.state != gone_r.state)
check("...and they read differently to a human, too",
empty_r.explain() != gone_r.explain()
and "NOT" in gone_r.explain() and "zero peak" in gone_r.explain())
check("an unreadable peak is None, not 0.0 - there is no number to mistake",
gone_r.peak_w is None and never_r.peak_w is None)
check("...and NO_RECORD carries no number either", empty_r.peak_w is None)
_typed = False
try:
float(gone_r.peak_w)
except TypeError:
_typed = True
check("...and a caller who forgets to ask gets a TypeError rather than a "
"silently disarming zero", _typed)
check("PeakReading exposes no accessor that defaults a missing peak to a "
"number - no .get(), no _or() ",
not [a for a in dir(gone_r)
if a.startswith(("get", "value")) or a.endswith("_or")])
# The consequence, which is the reason any of this matters.
print(f" headroom under a {LIVE_MAX_W:.0f} W limit: "
f"no quarter yet -> {good_tr.headroom_w(B)}, "
f"unreadable -> {gone_tr.headroom_w(B)}")
check("an unreadable store yields NO headroom figure - the disarming default "
"(full headroom, guard silently off) is unreachable",
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
check(f"...where a genuinely empty month yields the full {LIVE_MAX_W:.0f} W, "
f"which is correct and is a different answer",
near(good_tr.headroom_w(B), LIVE_MAX_W))
check("the 80 % warning fraction is likewise None when nothing was observed",
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
# And a real peak reduces headroom, so "always None" does not pass this part.
good.record_block(_CB(B, LIVE_MAX_W * 0.9, None))
print(f" after one {LIVE_MAX_W * 0.9:.0f} W quarter: "
f"headroom {good_tr.headroom_w(B):.0f} W, "
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
check("a real observed peak gives a real headroom, so this part is not passed "
"by a store that answers None to everything",
near(good_tr.headroom_w(B), LIVE_MAX_W * 0.1, 1e-6))
check(f"...and it is over §9.1's 80 % warning threshold",
good_tr.warning_frac(B) > 0.8)
# A CORRUPT database file is the third way a store fails, and it must land in
# NOT_OBSERVED too rather than raising into the control loop.
corrupt_path = TMP / "corrupt.db"
corrupt_path.write_bytes(b"this is not a database" * 64)
corrupt = PeakStore(str(corrupt_path))
c_r = PeakTracker(corrupt, cap_w=LIVE_MAX_W).peak(B)
print(f" a corrupt database file -> {c_r.state}")
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
# ==============================================================================
print()
print("6. the timezone fallback, graded against zoneinfo")
_zi = None
try:
from zoneinfo import ZoneInfo
_zi = ZoneInfo("Europe/Brussels")
except Exception as err: # noqa: BLE001
print(f" zoneinfo unavailable here ({err}) - the fallback IS the tz")
if _zi is None:
check("zoneinfo is absent, so the encoded rule cannot be graded here - "
"reported, not silently passed", True)
else:
fb = _EUTimezone()
# ⚠️ HOURLY, NOT 3-HOURLY, ACROSS THE TRANSITION DAYS. Both EU transitions
# happen at 01:00 UTC, so a rule that is wrong BY AN HOUR differs from the
# right one only between 01:00 and 02:00 UTC. A 3-hourly sweep steps over
# that window entirely and grades nothing - measured: a mutation moving the
# UTC threshold from 01:00 to 02:00 survived the whole file at 3-hourly.
probes = [datetime(2026, 1, 1, 12, tzinfo=timezone.utc)]
probes += [datetime(2026, m, d, h, mi, tzinfo=timezone.utc)
for m in (3, 10) for d in (28, 29, 30, 31)
for h in range(24) for mi in (0, 30)]
probes += [datetime(2026, m, 1, 0, 30, tzinfo=timezone.utc) for m in range(1, 13)]
# ⚠️ COMPARE THE CONVERTED WALL CLOCK, NOT ITS utcoffset(). `astimezone`
# goes through `fromutc`, but the RESULT carries local wall values, so
# asking it for `.utcoffset()` routes straight back into the LOCAL arm and
# never grades `fromutc` at all. Measured: a mutation moving the UTC
# threshold by an hour survived 397 offset-comparing probes and dies
# immediately on the wall clock they produce.
def _wallclock(t, tz):
return t.astimezone(tz).replace(tzinfo=None)
bad = [t for t in probes if _wallclock(t, fb) != _wallclock(t, _zi)]
print(f" {len(probes)} probes across both DST transitions and every "
f"month boundary: {len(bad)} disagreement(s) in the LOCAL WALL CLOCK "
f"each one converts to")
check("the encoded EU rule converts every probe to the same local wall "
"clock as zoneinfo, including the two transition weekends", not bad)
# ...and the probes are not all the same offset, or the check is vacuous.
offsets = {t.astimezone(_zi).utcoffset() for t in probes}
check(f"...and the probe set spans BOTH offsets ({sorted(str(o) for o in offsets)}), "
f"so agreeing is not free", len(offsets) == 2)
check(f"BILLING_TZ resolved to zoneinfo on this host, not the fallback",
not isinstance(BILLING_TZ, _EUTimezone))
# ⚠️ THE PROBES ABOVE ONLY EXERCISE `fromutc()`. `astimezone` on a UTC
# instant never calls `utcoffset()` with a local wall time, so the local
# arm of the rule was ungraded - a mutation that moved it by an hour
# survived the whole file. These probes drive it directly. Noon is used
# because it is unambiguous on every day of the year, including the two
# transition days where 02:00-03:00 local is missing or repeated.
#
# ⚠️ AND THE LOCAL PROBES MUST INCLUDE 03:00-03:59 ON THE OCTOBER SUNDAY.
# That hour is where a local rule that ends DST an hour late still claims
# CEST while zoneinfo has already returned to CET; at noon both answers
# agree and the probe is worth nothing. Measured: at noon-only, a mutation
# moving the local thresholds from 02:00/03:00 to 03:00/04:00 survived.
#
# The MARCH gap (02:00-02:59 local, which does not exist) is deliberately
# NOT probed: Python hands a nonexistent local time to `utcoffset` as an
# ordinary value and any answer is defensible, so grading it would pin an
# arbitrary choice. It cannot move a month boundary - it is the last Sunday
# of March, never the 1st of a month - which is the only thing this tz is
# used for.
wall = [datetime(2026, m, d, 12) for m, d in
[(3, 27), (3, 28), (3, 29), (3, 30), (10, 24), (10, 25),
(10, 26), (10, 27)]]
wall += [datetime(2026, 10, 25, h, mi) for h in range(0, 6) for mi in (0, 30)
if h != 2] # 02:00 local is the ambiguous fold
wall += [datetime(2026, 3, 29, h, mi) for h in (0, 1, 3, 4, 5) for mi in (0, 30)]
wall += [datetime(2026, m, 1, 12) for m in range(1, 13)]
bad_w = [w for w in wall
if w.replace(tzinfo=fb).utcoffset() != w.replace(tzinfo=_zi).utcoffset()]
w_offsets = {w.replace(tzinfo=_zi).utcoffset() for w in wall}
print(f" {len(wall)} LOCAL-wall probes through utcoffset(): "
f"{len(bad_w)} disagreement(s), spanning "
f"{sorted(str(o) for o in w_offsets)}")
check("utcoffset() on local wall time matches zoneinfo across both "
"transition weekends - the arm astimezone() never reaches", not bad_w)
check("...and those probes span both offsets too", len(w_offsets) == 2)
bad_n = [w for w in wall
if w.replace(tzinfo=fb).tzname() != w.replace(tzinfo=_zi).tzname()]
check("...and tzname() agrees with zoneinfo at every one of them (CET/CEST)",
not bad_n)
# ==============================================================================
print()
print("7. the per-phase reduction, on an UNBALANCED three-phase load (§17)")
# One phase exporting is what separates the three policies. On a balanced load
# they all give the same number, which is why this fixture is not balanced.
PH = (3000.0, 500.0, -200.0)
NET = sum(PH)
u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
u_closed = []
for i in range(0, QUARTER_S + 1, 5):
u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH))
blk = u_closed[0]
vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
print(f" phases {PH} (net {NET:+.0f} W) -> "
+ ", ".join(f"{k}={v:.0f} W" for k, v in vals.items()))
check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)",
near(vals[POLICY_NET], NET, 1e-6))
check(f"the SUM policy clips each phase first, so the exporting phase does not "
f"net off ({sum(max(v, 0.0) for v in PH):.0f} W)",
near(vals[POLICY_SUM], sum(max(v, 0.0) for v in PH), 1e-6))
check(f"the MAX policy bills the worst single phase ({max(PH):.0f} W)",
near(vals[POLICY_MAX], max(PH), 1e-6))
check("...and on THIS fixture all three are different numbers - which is the "
"whole reason the reduction is a named policy and not an assumption",
len({round(v, 6) for v in vals.values()}) == 3)
check("the shipped default is NET, the meter's own billed register",
billed_w(blk) == vals[POLICY_NET])
bal = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
b_closed = []
for i in range(0, QUARTER_S + 1, 5):
b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3,
phase_net=(1000.0, 1000.0, 1000.0)))
b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), "
f"so a balanced fixture could not have told them apart",
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
# ==============================================================================
print()
print("8. monthly_peak is DERIVED, and provably has not diverged")
st8 = fresh_store("derive")
QUARTERS = [(0, 1200.0), (1, 4998.0), (2, 3357.0), (3, 800.0), (4, 4832.0)]
for q, w in QUARTERS:
st8.record_block(_CB(B + timedelta(seconds=q * QUARTER_S), w, None))
MAXQ = max(w for _, w in QUARTERS)
stored = st8.peak(month=month_key(B))
derived = st8.recompute_month(month_key(B))
print(f" {len(QUARTERS)} quarters banked; stored peak {stored.peak_w:.0f} W, "
f"recomputed from quarter_blocks {derived.peak_w:.0f} W")
check(f"the stored monthly peak is the largest quarter ({MAXQ:.0f} W - "
f"specs.md:583's own figure)",
stored.is_observed and near(stored.peak_w, MAXQ))
check("...and re-deriving it from the 15-min tier gives the same number and "
"the same quarter, so the two records are proven equal rather than "
"assumed to be",
derived.is_observed and near(derived.peak_w, stored.peak_w)
and derived.quarter_start == stored.quarter_start)
check("...and the two measured regressions from §9.1 land on DIFFERENT peaks - "
f"the sustained {MAXQ:.0f} W quarter, not the oscillating 3357 W one",
not near(stored.peak_w, 3357.0))
# A quarter arriving LATER and lower must not lower the month.
st8.record_block(_CB(B + timedelta(seconds=9 * QUARTER_S), 100.0, None))
check("a later, smaller quarter does not lower the month's peak",
near(st8.peak(month=month_key(B)).peak_w, MAXQ))
check("...and the recompute agrees with it after that write, too",
near(st8.recompute_month(month_key(B)).peak_w, MAXQ))
# No pruning: §15 says these two tiers are indefinite / permanent.
src = (HERE / "app" / "tariff.py").read_text(encoding="utf-8")
check("§15's retention is structural: no DELETE against quarter_blocks or "
"monthly_peak exists anywhere in tariff.py",
"DELETE FROM quarter_blocks" not in src
and "DELETE FROM monthly_peak" not in src
and "DROP TABLE" not in src)
# ==============================================================================
print()
print("9. AC 3, on the replay data that actually exists")
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
print(f" replay fixtures found: {len(REPLAY)} "
f"({', '.join(p.name for p in REPLAY) or 'none'})")
check("the committed replay fixtures are present - a regression fixture CI "
"cannot see is not a regression fixture", len(REPLAY) >= 2)
# ⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with the
# default, once under `schema:` with `int(500,15000)`. Taking the last match
# would blow up on the schema line, and taking any match that parses would be
# luck; the FIRST is the options block, which is the number an install lands on.
SHIPPED_CAP_W = None
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
if line.strip().startswith("peak_cap_w:"):
try:
SHIPPED_CAP_W = float(line.split(":", 1)[1].strip())
except ValueError:
continue
break
check("config.yaml's shipped peak_cap_w was read out of the file rather than "
"typed here", SHIPPED_CAP_W is not None)
replay_days = 0
total_q = 0
under = 0
for path in REPLAY:
st9 = fresh_store("replay-" + path.stem)
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_MAX_W)
first = last = None
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
t = datetime.fromisoformat(row["t"])
first = first or t
last = t
ing.submit(sample(t, float(row["w"])))
tr9.sync(ing)
months = {month_key(b.start) for b in ing.blocks}
span_h = (last - first).total_seconds() / 3600.0
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
peak = max(qs) if qs else 0.0
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_MAX_W))
total_q += len(qs)
under += ok_q
replay_days += span_h / 24.0
print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, "
f"month peak {peak:.0f} W, "
f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the "
f"{SHIPPED_CAP_W:.0f} W soft limit")
check(f"{path.name}: the month's peak equals its own largest quarter",
near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
check(f"{path.name}: a full day of real telegrams produces ~96 quarters, "
f"got {len(qs)}", 90 <= len(qs) <= 96)
PCT = 100.0 * under / max(total_q, 1)
print()
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
print(f" AC 3 ASKS FOR 30 DAYS. {replay_days:.1f} days exist. "
f"AC 3 IS NOT MET and this run does not claim it is - see the module "
f"docstring. The figure above is the BASELINE SAFETY-07 has to improve.")
check("the replay produced a measurable number over a stated sample size, "
"rather than a criterion quietly redefined to fit the data",
total_q > 100 and replay_days < 30)
# ==============================================================================
print()
print("10. the wiring: the real Controller, and what its status page says")
class _Store:
data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
async def limits(self, entity):
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
async def number(self, entity, invert=False):
return None
async def set_number(self, entity, value):
return True
async def call(self, domain, service, data):
return True
def built(db_path, opts=None):
return Controller(dict(
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
"p1_net_entity": "sensor.net", "meter_phases": 1,
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
"stale_input_s": 15}, **(opts or {})),
_Hass(), _Store(), _Pub(), stop=asyncio.Event(),
peak_db_path=str(db_path))
c = built(TMP / "wired.db")
check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED "
f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot "
f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W)
# Drive one real quarter through the real ingest, then the real cycle's sync.
for i in range(0, QUARTER_S + 1, 5):
c.p1.submit(sample(B + timedelta(seconds=i), 4998.0))
n = c.peak.sync(c.p1)
wr = c.peak.peak(B)
print(f" after one 4998 W quarter through the real ingest: "
f"{n} banked, {wr.explain()}")
check("the closed quarter reached SQLite through the tracker the cycle calls",
n == 1 and wr.is_observed and near(wr.peak_w, 4998.0, 1e-6))
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
# same row per block, and `recorded` - the number the status page prints as
# "quarters banked" - would be a count of loop iterations rather than of
# quarters. The database stays correct either way, which is exactly why this
# needs its own check instead of being caught by the peak.
again = c.peak.sync(c.p1)
third = c.peak.sync(c.p1)
print(f" sync() called three times over the same closed quarter: "
f"{n}, {again}, {third} newly banked; tracker reports "
f"{c.peak.recorded} quarter(s)")
check("...and syncing the same quarter again banks nothing new, so the count "
"the status page shows is quarters and not cycles",
again == 0 and third == 0 and c.peak.recorded == 1)
check("...and the peak is unchanged by the repeat",
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
rows = [r for r in c.checks() if "capacity-tariff" in r["text"] or "peak" in r["text"]]
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row",
len(peak_rows) == 1)
check("...naming the peak, the soft limit and the quarters banked, all derived "
"from the controller that was actually built",
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
and "1 quarter(s) banked" in peak_rows[0]["text"])
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {SHIPPED_CAP_W:.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
# The half that matters: the same page with a store that cannot answer.
c2 = built(TMP / "no-such-dir" / "nope.db")
u_rows = [r for r in c2.checks() if "capacity-tariff peak" in r["text"]]
print(" status row (unreadable store): "
+ (u_rows[0]["text"] if u_rows else "(none)"))
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
"instrument looks exactly like a month with no peak",
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
check("...and the healthy row and the unreadable row are different text",
bool(u_rows) and bool(peak_rows)
and u_rows[0]["text"] != peak_rows[0]["text"])
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
# SYNTAX, not on the text: the module docstring argues this at length and names
# every forbidden word while doing so, so a grep over the source would be
# satisfied by deleting the explanation and would fail on writing one. What must
# be true is that no NAME the code evaluates refers to a command.
import ast as _ast
_tree = _ast.parse(src)
_names = {n.id for n in _ast.walk(_tree) if isinstance(n, _ast.Name)}
_names |= {n.attr for n in _ast.walk(_tree) if isinstance(n, _ast.Attribute)}
_names |= {n.name for n in _ast.walk(_tree)
if isinstance(n, (_ast.FunctionDef, _ast.ClassDef))}
_FORBIDDEN = {"setpoint", "write_setpoint", "target", "Decision", "i_clamped",
"clamped", "Claim", "resolve", "P_PEAK", "arbiter"}
_found = sorted(_names & _FORBIDDEN)
_imports = sorted(m.module or "" for m in _ast.walk(_tree)
if isinstance(m, _ast.ImportFrom))
print(f" tariff.py evaluates {len(_names)} distinct names and imports "
f"from {_imports or ['(stdlib only)']}")
check(f"none of them is a command-side name {sorted(_FORBIDDEN)} - the utility "
f"bills what FLOWED, so this reads the meter and nothing else"
+ (f"; found {_found}" if _found else ""), not _found)
check("...and it imports nothing from arbiter, clamp or control: it files no "
"claim, applies no clamp, and commands nothing - SAFETY-07's job",
not [m for m in _imports if m in ("arbiter", "clamp", "control",
".arbiter", ".clamp", ".control")])
# ...and the guard is not vacuous: the same grader run over main.py, which DOES
# command, must light up.
_main = {n.attr for n in _ast.walk(
_ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")))
if isinstance(n, _ast.Attribute)}
check(f"the same grader over main.py finds {sorted(_main & _FORBIDDEN)}, so it "
f"is capable of failing", bool(_main & _FORBIDDEN))
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")