Compare commits

..
8 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 71cd7849e4 Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py
and the arbiter, none of which this branch touches. Both entries kept.

X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed
the SAME provisional point as both cheapest and dearest: a guard on either role
alone raises, so the stimulus could not tell "the buy hour is guarded" from
"both are". Now one call per role with a firm point in the other seat, plus the
positive arm so the two refusals are discriminating.

X6. `firm_points()` returning the carried hours too survived, because its two
consumers - the §11.4 refusal and cheap_now - guard provenance independently and
both keep answering correctly. The accessor is now graded directly: exactly the
published points, by identity and in order.

Both are the degeneracy rule this file states at the top, in a new dress - a
stimulus that cannot distinguish the two things it grades.

⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale
__pycache__. Reverting `self.provisional` to `here.provisional` changes neither
the file size nor, at one-second resolution, the mtime, so CPython kept the
MUTATED bytecode and a green run afterwards proved nothing about that line. Every
mutation here now clears __pycache__ first; re-run that way, M9 is caught. The
committed source was always correct - the checks were not being run against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:15:32 +02:00
admin e7c95bfd8a SAFETY-07: cap the loop before it sets the month's billed capacity peak (#18)
The first thing in this system that can STOP a runaway rather than alarm on one. Each cycle it projects the open 900 s block from P1 telegrams and, over the ceiling, files one arbiter claim: a lower bound at P_PEAK, sized as excess = 900*(projected-ceiling)/remaining added to the MEASURED battery power. No writes, no I/O, no clock, no state between calls.

Replayed independently by the reviewer on a separate harness: 2026-08-20's billed quarter 5029.8 W -> 3485.3 W, both days 189/190 -> 190/190 under the 3500 W soft limit, 878 Wh modelled discharge on the failure day and 0 Wh with 0 engagements on the healthy one.

Verified as properties rather than witnesses: the lo-limit claim cannot cap discharge or export (hi is inf; 0 of 54 loop/floor pairs lowered the target; no floor in 0..5000 W reduces a full-rate discharge; decide() emitted 0 negative floors over 630 input combinations), and the 900/remaining gain is exactly 1.0 at the top of the block, growing, bounded at the end by the achievable_w clamp so it can never file lo > hi.

Round one held this PR on three wiring mutations that left all twelve suites green while a default install parked at 0 W in the billed quarter - including the suppression re-gated on the optional peak_forecast_entity, verbatim the historical defect this card's own comment described. All three are closed. The conjunction moved out of main.py into peakguard.shaping_suspended() so the check can assert on the shipped expression, and part 7 now executes the mutations against the real Controller.cycle(). The reviewer confirmed the harness cannot go vacuous: re-inlining the conjunction in main.py with behaviour-identical code turns B1 and B2 RED rather than green, because they assert the mutation takes effect.

Eight further reviewer mutations this round, all red, no survivors. One note recorded, not blocking: the parsed-syntax grade on decide(batt_w=...) is defeated by a decoy .decide() call or by overwriting self.batt before the call, and it is the behavioural checks beside it that catch both.

AC 3 remains TARIFF-01's and is NOT met - do not close the card as AC 3 met. SAFETY-24, the missing SoC backstop on the forced discharge, is named in peakguard.py with the 878 Wh figure and must land before this reaches a client install.
2026-08-30 23:56:20 +02:00
glenn schrooyenandClaude Opus 5 dd40c56ab4 FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:54:07 +02:00
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 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
12 changed files with 3888 additions and 62 deletions
+130
View File
@@ -2,6 +2,136 @@
## Unreleased
**Provisional prices, and what may not rest on one (FCST-03).**
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
the unknown hours use yesterday's profile and are flagged `provisional: true` —
and **no irreversible action may be taken on a provisional price.**
`app/price_forecast.py` does the fill, and the whole module is shaped by one
defect: a carried-forward point and a published one are the same three fields
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
copies yesterday's `False` onto today's guess and produces stale data presented
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
arrives on time, looks fresh, and is wrong.
**So the flag is never passed through.** Carried points are built by a
constructor that has no `provisional` parameter at all (the literal `True` lives
in one place, and there is nothing for a caller to forget or copy); published
points are passed through **by identity**, so there is no copy that could lose a
flag; and an audit re-derives the expected flag for every emitted point from the
set of hours the feed actually published and *raises* rather than repairing one.
A published point may itself be provisional — `eneco_eddy`'s current-quarter
imbalance print is — so the audit's rule for a published hour is "whatever the
feed said", never "False".
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
every carried hour by one across a transition and misprices a whole day with
nothing to show for it. Re-running the fill after a late publication replaces
the carried hours with the real print: the number and the flag move together
because the series is rebuilt, never patched.
**Arbitrage now drops provisional hours instead of refusing the whole day.**
The refusal that read "the plan is provisional" would have switched arbitrage off
every morning on every dynamic site once carry-forward filling landed — a
part-firm series is the *normal* state before the print. What §8.3 forbids is
acting **on** a provisional price, so the provisional hours are dropped and the
spread, the cheapest hour and the dearest hour are computed over the published
ones only; fewer than two firm hours is still a refusal, which is where the
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
be *constructed* around a provisional point at all. The cheap-window flag became
per-hour for the same reason, so a site's maintenance charge floor does not go
dark every morning.
**Real-time / imbalance products get measurably wider thresholds.** One widening
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
2, derived from the declared `PriceProduct` and never from a plugin id) and
§11.4's "no weather data" row, which the spec widens by a different trigger and
which is the same mechanism. The combination rule is `max`, not a product — two
independent doubts must not compound into a threshold nothing can clear. The
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
the number *is*; this owns only how much wider it gets and why, and the allowed
verdict reports the threshold it cleared.
**An irreversible action is a value, not a judgement call at each call site.**
`Irreversible` carries the hours an action commits — the maintenance drain's
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
and the gate looks at exactly those hours, blocking on a provisional one, on an
hour the series does not cover, and on no price data at all: three different
reasons, none of them an answer. ⚠️ It is the entry point for a start being
*justified by a price*; the calendar path main.py runs today is untouched,
because a gate that blocked on `no_data` would stop the monthly cycle on every
default install — §12's opening sentence.
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
**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
+68 -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)
+221 -4
View File
@@ -33,17 +33,20 @@ 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
@@ -189,6 +192,22 @@ class Controller:
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.
@@ -424,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,
@@ -435,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:
@@ -552,6 +677,11 @@ 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
@@ -643,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.
@@ -918,6 +1101,40 @@ class Controller:
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:
+20
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."""
+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"))
+81
View File
@@ -282,6 +282,87 @@ def volatility_regime(product: PriceProduct) -> str:
return "wide" if product in _WIDE else "normal"
# --------------------------------------------------------------------------- #
# FCST-03: ONE widening path, driven by a SET of active uncertainty sources.
#
# Two spec rows widen the same number by two different triggers:
#
# §9.2 / §8.3 real-time / imbalance product -> "arbitrage thresholds widen"
# §11.4 no weather data -> "widen arbitrage thresholds"
#
# They are one mechanism reached twice, not two mechanisms, so there is one
# function and a set of reasons. ⚠️ THE COMBINATION RULE IS `max`, NOT A
# PRODUCT: multiplying 2.5 by 1.5 would make two independent doubts compound
# into a threshold nothing on a Belgian tariff can ever clear, and an arbitrage
# strategy that can never fire is indistinguishable from one that is switched
# off. The widest single reason wins.
#
# ⚠️ HONEST ABOUT WHERE THE NUMBERS COME FROM. specs.md says thresholds widen
# and does not say by how much; there is no capture to derive a factor from,
# because no arbitrage strategy has ever run here. These are POLICY DEFAULTS,
# chosen so that (a) a real-time product needs a materially larger spread than
# a settled day-ahead one before a cycle is worth its wear, and (b) the two
# factors DIFFER, so `max`, `sum` and `product` are three distinguishable
# answers rather than the same number - a check that grades a combination rule
# where all the candidates agree grades nothing. When OPT-04 lands
# `cycle_cost_threshold_eur` (§10.1/§14) these become the natural second option
# beside it, in PRICE_OPTION_DEFAULTS and nowhere else (CFG-03).
class Uncertainty(Enum):
"""Why an arbitrage threshold is currently wider than the tariff alone says."""
REALTIME_PRODUCT = "realtime_product" # §9.2 row 2, §8.3
NO_WEATHER = "no_weather" # §11.4 "no weather data"
WIDEN_FACTOR = {
Uncertainty.REALTIME_PRODUCT: 2.5,
Uncertainty.NO_WEATHER: 1.5,
}
def uncertainty_of(product: PriceProduct) -> frozenset:
"""The uncertainty a PRODUCT contributes on its own (§9.2 row 2).
Derived from the declared product, never from a plugin id - the same rule
`volatility_regime()` exists for. A sixth supplier declaring
`PriceProduct.REALTIME` is widened without anyone editing this file.
"""
if not isinstance(product, PriceProduct):
raise TypeError(f"uncertainty_of() takes a PriceProduct, not "
f"{product!r} - a plugin name is what §9.3 forbids.")
return (frozenset({Uncertainty.REALTIME_PRODUCT})
if product in _WIDE else frozenset())
def widen_factor(sources) -> float:
"""The multiplier the widest active source asks for. 1.0 when there are none.
⚠️ A STRING IS REFUSED. `widen_factor({"realtime"})` would otherwise be a
silent 1.0 - a widening that reports itself as applied and is not, which is
the shape of defect this repo keeps finding.
"""
srcs = frozenset(sources)
bad = [s for s in srcs if not isinstance(s, Uncertainty)]
if bad:
raise TypeError(f"widen_factor() takes Uncertainty members, not {bad!r}. "
f"A string here would widen by 1.0 and say it widened.")
return max((WIDEN_FACTOR[s] for s in srcs), default=1.0)
def required_spread(base_eur_kwh: float, sources) -> float:
"""The spread an arbitrage cycle must clear, given what is uncertain (§8.3).
`base_eur_kwh` is the caller's own round-trip cost threshold - OPT-04 owns
that number (§10.1's `cycle_cost_threshold_eur`), this owns only how much
wider it gets and why.
"""
if isinstance(base_eur_kwh, bool) or not isinstance(base_eur_kwh, (int, float)) or not math.isfinite(base_eur_kwh) or base_eur_kwh < 0:
raise ValueError(
f"required_spread() takes a non-negative, finite base threshold in "
f"EUR/kWh, not {base_eur_kwh!r}. A negative or NaN base makes every "
f"spread clear it, which is arbitrage with the brake removed.")
return round(float(base_eur_kwh) * widen_factor(sources), 6)
@dataclass(frozen=True)
class PricePoint:
"""One canonical point. THREE FIELDS. §9.3 lists three; this has three."""
+465
View File
@@ -0,0 +1,465 @@
"""FCST-03 - the hours the feed has not published yet, and what may not rest on them.
WHERE THIS LIVES. specs.md §2.2 puts price ingest under `ems/ingest/prices/`.
That 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/`. The
BEHAVIOUR §8.3 describes is unchanged and still authoritative - only the path
moved. Read `ems/...` as `app/...`.
§8.3, in full, because every line below is one clause of it:
"Day-ahead prices arrive once daily. Until they do, unknown hours use
yesterday's profile and are flagged `provisional: true` - and NO
IRREVERSIBLE ACTION MAY BE TAKEN ON A PROVISIONAL PRICE. Real-time /
imbalance products are treated as high-volatility: arbitrage thresholds
widen."
Three separate things, and this module owns the first two. The third - the
widening arithmetic - lives in price.py beside the product enum it is derived
from (`Uncertainty`, `widen_factor`, `required_spread`), and is spent by
`static_tariff.TariffPlan.arbitrage()`, because that is where the verdict this
repo already refuses to hand out casually is produced. FCST-03 added a refusal
to that existing ladder rather than a second mechanism beside it.
THE DEFECT THIS MODULE IS SHAPED BY: A COPY THAT KEEPS THE WRONG FLAG
=====================================================================
A point filled from yesterday and a point published this morning are the SAME
SHAPE. Three floats and a bool; only the bool tells them apart. And the natural
way to write a carry-forward fill is:
PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)
^^^^^^^^^^^^^
which copies yesterday's `False` onto today's guess and produces exactly the
thing the ticket exists to prevent: STALE DATA PRESENTED AS CURRENT. It reads
fine, it type-checks, it round-trips through §9.3's JSON, and every price on
the status page looks like a price. This is the meter-side SAFETY-18 defect one
layer up: the reading arrives on time, looks fresh, and is wrong - and a
freshness check that only asks "did something arrive" cannot see it.
So the flag is not passed through anywhere in this file:
* A CARRIED POINT IS BUILT BY `_carried()`, WHICH HAS NO PARAMETER FOR THE
FLAG. `provisional=True` is a literal in one place. There is no argument a
caller can pass, no keyword to forget, and no variable that could hold
yesterday's value at the moment the point is constructed.
* A PUBLISHED POINT IS NEVER COPIED AT ALL. It is passed through BY IDENTITY -
the object the provider produced is the object in the output series - so
there is no copy that could lose or gain a flag. `filled.points[i] is
fetched.points[j]` is a checkable property, and test_price_forecast.py
checks it.
* `_audit()` RE-DERIVES the expected flag for every output point from the set
of hours the feed actually published, and RAISES if any point disagrees.
It is a post-condition, not a filter: it never fixes a flag, because a
module that quietly corrects its own provenance is one whose provenance
cannot be trusted.
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
the pass-through idiom cannot come back in a later edit and pass review by
looking ordinary.
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
current-quarter print is genuinely provisional and says so per point (§9.2,
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
a published point is "the flag is whatever the feed said", never "False". A
provenance check that forced published points to False would be a bug that
looks like a guarantee.
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
===========================================
For every whole hour in the requested horizon that the feed has not published,
take yesterday's value for THE SAME BRUSSELS WALL-CLOCK HOUR - not `t - 86400
s`. On the two days a year that matter those are different instants, and the
difference is not academic: a 23-hour day has no 02:00 at all and a 25-hour day
has two, so a fill that subtracts a fixed 24 h shifts every carried hour by one
across a transition and misprices the whole day in a way nothing reports.
On the 25-hour day the two 02:00 hours - `02:00+02:00` and `02:00+01:00` - both
key to local hour 2 and are BOTH filled from yesterday's single 02:00 value.
That is deliberate: they are both guesses, they are both flagged, and nothing
irreversible may rest on either. If yesterday itself was a 25-hour day, the
first of its two 02:00 points is the one used, deterministically.
An hour with no published price AND no yesterday value is LEFT OUT of the
series. It is not filled with a neighbour, a mean, or a zero. §11.4 applies to
that hour - `_Series.at()` raises `Absence` for it - and the caller that wanted
a tariff for the whole horizon falls back to the static profile (TARIFF-03),
which is the row the spec actually writes for "no price data". If NOTHING can
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
because an empty series is not constructible on purpose (price.py trap 3).
AC2: THE IRREVERSIBLE-ACTION GATE
=================================
"The distinction should be an explicit property of the action, not a judgement
call at each call site" - so an irreversible action is a value, `Irreversible`,
carrying the number of hours it COMMITS. `price_permits()` looks at exactly
those hours and answers with a `Gate` variant that carries the evidence on the
permitting arm and a reason on the blocking one, the `_Outcome` shape price.py
borrowed from `sim/riglock.py`.
The shipped example is the maintenance drain (§12): starting one commits hours
of forced discharge that cannot be undone if the price turns out to have been
yesterday's. Its commitment window is DERIVED from `maintenance.DRAIN_TIMEOUT_MIN`
rather than typed here, so the gate and the phase machine cannot drift apart.
⚠️ WHAT DOES NOT GO THROUGH THIS GATE, AND WHY THAT MATTERS MORE THAN WHAT
DOES. The maintenance cycle is a CALENDAR obligation (§12.2: a cycle that
silently never fires is indistinguishable from one that is not due yet), and a
site with `price_provider: off` - the shipped default - has no price series at
all. Routing every maintenance start through a gate that blocks on `no_data`
would stop the monthly cycle on every default install, permanently and
silently: the exact failure §12 opens by naming. So `begin_phase()` is the
entry point for a start that is being JUSTIFIED BY A PRICE, main.py's existing
calendar path is untouched by this ticket, and OPT-04/MAINT-05 own the decision
of which one a site runs. Nothing here disables anything that runs today.
Reversible actions - ordinary self-consumption modulation, the loop that
actually holds the grid at zero - are not gated at all and cannot be: §6 says
absent machinery must not disable the loop.
"""
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
_LOG = logging.getLogger("goodwe.price.forecast")
class Provenance(Enum):
"""Where a point in a filled series came from. INTERNAL - §9.3's wire format
carries the boolean and nothing else, so this never leaves the module.
It exists so `_audit()` has a vocabulary to state its post-condition in, and
so the two cases have names in the log rather than being "the ones with the
flag" and "the others".
"""
PUBLISHED = "published" # the provider printed this hour. Flag: the
# feed's own - an imbalance print may be
# published AND provisional (§9.2).
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
# Flag: ALWAYS True, by construction.
# --------------------------------------------------------------------------- #
# The fill
# --------------------------------------------------------------------------- #
def _utc_hour(instant: datetime) -> datetime:
u = instant.astimezone(timezone.utc)
return u.replace(minute=0, second=0, microsecond=0)
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
⚠️ THERE IS NO `provisional` PARAMETER, AND THAT IS THE MECHANISM. This is
the only constructor of a carried point, the flag is a literal, and no
caller - including a future one editing in a hurry - has anything to pass,
default, or copy from the point being carried.
"""
return PricePoint(start=brussels(start_utc),
eur_per_kwh=round(float(eur_per_kwh), 6),
provisional=True)
def _audit(points, published) -> None:
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
`published` maps a UTC hour to the point the provider produced for it.
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
provenance is one whose provenance means nothing; the only safe response to
"this point's flag disagrees with where it came from" is to refuse to emit
the series at all.
"""
seen = set()
for p in points:
u = _utc_hour(p.start)
if u in seen:
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
f"duplicated hour is how a DST day gets mispriced")
seen.add(u)
src = published.get(u)
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
if src is None:
if p.provisional is not True:
raise ValueError(
f"{u.isoformat()} is {prov.value} and is flagged "
f"provisional={p.provisional!r}. §8.3: an hour filled from "
f"yesterday's profile is provisional whether or not the "
f"number turns out to be right - the flag is a claim about "
f"PROVENANCE, not about accuracy.")
else:
if p is not src:
raise ValueError(
f"{u.isoformat()} is {prov.value} but the series carries a "
f"COPY of that point rather than the point itself. A copy "
f"is where the provisional flag gets lost; published points "
f"pass through by identity.")
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
"""§8.3: fill the hours the feed has not published with yesterday's profile.
Every argument is required and keyword-only past the first. In particular
`yesterday` has NO DEFAULT: a fill with no yesterday to fill from is a
different situation from a fill that found nothing useful in one, and a
default `None` would make the two arrive looking identical.
Returns a `PriceRead`:
* a series, whose points are the provider's own objects for the hours it
published and `_carried()` points for the rest, ordered by instant;
* `no_data` when neither source can produce a single hour - never an
empty series, which is not constructible (price.py trap 3).
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
retries and this runs again against the fresher read. Published hours always
win over carried ones, so a re-run REPLACES a carried point with the real
print and the hour becomes firm - the flag and the number move together
because the whole series is rebuilt rather than patched in place.
"""
for name, value in (("since", since), ("until", until), ("stamp", stamp)):
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{name} must be tz-aware; a naive instant is "
f"ambiguous for one hour every October")
published = read.match(
series=lambda r: {_utc_hour(p.start): p for p in r.points},
no_data=lambda _r: {})
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
by_local_hour = {}
yesterday.match(
series=lambda r: [by_local_hour.setdefault(brussels(p.start).hour, p)
for p in r.points],
no_data=lambda _r: [])
out = []
u = _utc_hour(since)
end = until.astimezone(timezone.utc)
while u < end:
if u in published:
out.append(published[u]) # BY IDENTITY. No copy.
else:
src = by_local_hour.get(brussels(u).hour)
if src is not None:
out.append(_carried(u, src.eur_per_kwh))
u += timedelta(hours=1)
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
if carried:
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; %d published",
carried, len(out), len(out) - carried)
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
provider = read.match(series=lambda r: r.provider,
no_data=lambda r: yesterday.provider)
return PriceRead.series(out, product, provider, stamp)
# --------------------------------------------------------------------------- #
# AC2: irreversibility as a property of the ACTION, and the gate that reads it
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Irreversible:
"""An action that cannot be undone once started, and how far it commits.
`commits_h` is the horizon the action ties up - the hours whose prices must
therefore be firm before it may be justified by a price at all.
"""
name: str # the §10.2 reason code
commits_h: float
def __post_init__(self):
if not self.name or not isinstance(self.name, str):
raise ValueError("an irreversible action needs a reason code (§10.2)")
if isinstance(self.commits_h, bool) or \
not isinstance(self.commits_h, (int, float)) or \
not self.commits_h > 0:
raise ValueError(
f"commits_h must be a positive number of hours, not "
f"{self.commits_h!r}. An action that commits nothing is a "
f"reversible one and does not belong here.")
# ⚠️ DERIVED FROM THE PHASE MACHINE'S OWN BOUNDS, NOT TYPED HERE. maintenance.py
# already states how long each phase may run before it is a fault; a second copy
# of those numbers here would be a place for them to drift apart, and the drift
# would show up as a gate that checked the wrong hours.
MAINTENANCE_DRAIN = Irreversible("MAINTENANCE_DRAIN", DRAIN_TIMEOUT_MIN / 60.0)
MAINTENANCE_CHARGE = Irreversible("MAINTENANCE_CHARGE", CHARGE_TIMEOUT_MIN / 60.0)
class Gate(_Outcome):
"""May an irreversible action proceed on the price evidence available?
* `permitted` - carries `.action` and `.hours`, the firm points that were
actually checked. The evidence exists only on this arm.
* `blocked` - carries `.reason`. Nothing else; `.hours` raises `Absence`.
No `__bool__`, both `match()` arms required and keyword-only, for the reason
price.py's `_Outcome` gives: `if gate:` must not look like a guard while
being none.
"""
def match(self, *, permitted, blocked):
return {"permitted": permitted, "blocked": blocked}[self.kind](self)
@staticmethod
def permit(action, hours):
return _Permitted(action, hours)
@staticmethod
def block(reason):
if not reason or not isinstance(reason, str):
raise ValueError(
"Gate.block() takes a reason a human can act on. §6 requires "
"every decision to explain itself, and 'the maintenance cycle "
"did not start' with no reason is indistinguishable from a bug.")
return _Blocked(reason)
class _Permitted(Gate):
kind = "permitted"
def __init__(self, action, hours):
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
pts = tuple(hours)
if not pts:
raise ValueError(
"a permission granted over ZERO checked hours is a permission "
"granted on no evidence - the shape this whole file exists to "
"refuse. Block instead.")
if any(p.provisional for p in pts):
raise ValueError(
"a permitted gate cannot carry a provisional hour: §8.3 is the "
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
class _Blocked(Gate):
kind = "blocked"
def __init__(self, reason):
self.reason = reason
self.words = f"blocked - {reason}"
def committed_hours(now: datetime, action: Irreversible) -> tuple:
"""The whole hours `action` started at `now` would run through.
Split out because the gate and its caller must agree on what "the hours it
commits" means, and because a window that quietly excluded the last hour
would be a gate that permits an action on an hour it never looked at.
"""
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
if now.tzinfo is None or now.utcoffset() is None:
raise ValueError("now must be tz-aware")
out = []
u = _utc_hour(now)
end = now.astimezone(timezone.utc) + timedelta(hours=action.commits_h)
while u < end:
out.append(u)
u += timedelta(hours=1)
return tuple(out)
def price_permits(read: PriceRead, now: datetime,
action: Irreversible) -> Gate:
"""§8.3's rule, as a value: may this irreversible action rest on this price?
Blocks when the read observed nothing (§11.4 - an action justified by a
price nobody has is an action justified by nothing), when any hour it
commits has no price at all, and when any of those hours is provisional.
Names the offending hour, so an operator reading the log knows which one.
"""
hours = committed_hours(now, action)
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
pts = []
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
pts.append(p)
return Gate.permit(action, pts)
def begin_phase(maint, now: datetime, read: PriceRead,
action: Irreversible) -> Gate:
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why.
The AC2 example made real: `maint.force_start(now)` - the call that moves
the phase machine out of `idle` and commits the pack to hours of forced
discharge - happens on the permitting arm and cannot happen on the other
one, because the blocking arm has no code path to it.
⚠️ READ THE MODULE DOCSTRING'S LAST WARNING BEFORE WIRING THIS IN. This is
the price-justified entry point, not the calendar one; a site with no price
provider must keep starting its monthly cycle the way it does today, or
§12's opening sentence comes true - a cycle that silently never fires.
"""
gate = price_permits(read, now, action)
return gate.match(permitted=lambda g: _go(maint, now, g),
blocked=lambda g: _no(g))
def _go(maint, now, gate) -> Gate:
maint.force_start(now)
_LOG.info("%s: %s", gate.action.name, gate.words)
return gate
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
return gate
+192 -38
View File
@@ -77,25 +77,49 @@ 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
`peak_guard_active`, `min_spread_eur_kwh` and `uncertainty` as REQUIRED KEYWORD
ARGUMENTS, none of them defaulted. §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:
⚠️ FIVE 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
3. fewer than TWO FIRM HOURS §8.3. FCST-03 rewrote this one: it used
to be "any point in the series is
provisional", which would have switched
arbitrage off every morning on every
dynamic site once carry-forward filling
landed (a series is part-firm and
part-carried until the 14-16 h CET
publication). What §8.3 forbids is
acting ON a provisional price, so the
provisional hours are DROPPED and
everything below is computed over the
firm ones. A wholly provisional series -
the §11.4 fallback - still has no firm
pair and is still refused.
4. the FIRM series is FLAT max - min == 0, so there is nothing to
arbitrage. THIS is TARIFF-03's 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.
5. the spread does not clear the §8.3/§9.2/§11.4, new in FCST-03. The
WIDENED threshold caller states its round-trip cost
threshold; this widens it by the widest
active uncertainty source - a real-time
/ imbalance PRODUCT (derived from the
declared `PriceProduct`, never from a
plugin id) or the caller's own
`NO_WEATHER`. See price.py's
`required_spread()`; the combination
rule is `max`, not a product.
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
@@ -182,7 +206,8 @@ from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
_Outcome, brussels, load_prices, price_option,
Uncertainty, _Outcome, brussels, load_prices, price_option,
required_spread, uncertainty_of, widen_factor,
)
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -619,8 +644,9 @@ class Arbitrage(_Outcome):
return {"allowed": allowed, "refused": refused}[self.kind](self)
@staticmethod
def allow(spread, cheapest, dearest, regime, product):
return _Allowed(spread, cheapest, dearest, regime, product)
def allow(spread, cheapest, dearest, regime, product, required, sources):
return _Allowed(spread, cheapest, dearest, regime, product, required,
sources)
@staticmethod
def refuse(reason):
@@ -632,17 +658,43 @@ class Arbitrage(_Outcome):
return _Refused(reason)
# ⚠️ FIRM BY CONSTRUCTION, ASSERTED RATHER THAN TRUSTED (FCST-03). §8.3 forbids
# an irreversible action on a provisional price, and the two points on the
# permitting variant ARE the action: they are the hours a strategy buys and
# sells in. A provisional point reaching this far is a defect in the refusal
# ladder above, and it fails loudly at the moment of construction rather than
# quietly at the meter a day later.
def _firm_or_die(cheapest, dearest):
for role, pt in (("cheapest", cheapest), ("dearest", dearest)):
if getattr(pt, "provisional", True):
raise ValueError(
f"the {role} hour handed to an arbitrage strategy is "
f"PROVISIONAL ({getattr(pt, 'start', '?')}). §8.3: no "
f"irreversible action may be taken on a provisional price - "
f"an ALLOWED verdict cannot be built out of one.")
class _Allowed(Arbitrage):
kind = "allowed"
def __init__(self, spread, cheapest, dearest, regime, product):
def __init__(self, spread, cheapest, dearest, regime, product, required,
sources):
_firm_or_die(cheapest, dearest)
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
# FCST-03. The threshold this spread actually cleared, and why it was
# the size it was. An optimiser that wants to know how much margin it
# has does not get to re-derive it from a product enum.
self.required_spread_eur_kwh = required
self.uncertainty = frozenset(sources)
self.words = (f"spread {spread:.4f} EUR/kWh over "
f"{product.value} ({regime} thresholds)")
f"{product.value} ({regime} thresholds, cleared "
f"{required:.4f} EUR/kWh)")
class _Refused(Arbitrage):
@@ -724,36 +776,100 @@ class TariffPlan:
no_data=lambda r: _absent(r.reason))
# -- the two things a consumer is allowed to act on ----------------------
def firm_points(self) -> tuple:
"""The points that carry a PUBLISHED price for their own hour (§8.3).
The complement of the carried-forward fill FCST-03 adds. Empty on a
§11.4 fallback plan and on a `no_data` read - and empty is honest here,
because it is a count of observations, not a value invented in place of
one. Never used to answer "what does this hour cost"; only to answer
"may something irreversible rest on it".
"""
return self.read.match(
series=lambda r: tuple(p for p in r.points if not p.provisional),
no_data=lambda _r: ())
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".
⚠️ FALSE ON A PROVISIONAL HOUR, AND IT IS THIS HOUR THAT DECIDES
(FCST-03). §8.3 forbids an irreversible action on a provisional price
and a paid grid top-up of a battery is about as irreversible as this
add-on gets - but the price that would be paid is THIS hour's, so a
firm hour does not stop being cheap because tomorrow's hours have not
been published yet. Before FCST-03 the whole plan had to be firm, which
was the same answer for the only two series that existed then (all firm,
or all provisional) and would have switched the cheap window off every
morning once carry-forward filling landed.
The mean is taken over the FIRM hours for the same reason: yesterday's
carried values are not observations of what today costs, and a
comparison basis half-built out of them grades this hour against a
number nobody published. On an all-firm series - every series that
existed before FCST-03 - it is the same mean to the last digit.
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:
firm = self.firm_points()
if not firm:
return False
try:
return self.price_at(instant) < self.mean_eur_kwh()
here = self.read.match(series=lambda r: r.at(instant),
no_data=lambda r: _absent(r.reason))
except Absence:
# An instant outside the series is an hour with no price (§11.4 for
# that hour), not a cheap one.
return False
if here.provisional:
return False
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
def arbitrage(self, *, peak_guard_active: bool) -> Arbitrage:
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
uncertainty) -> 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.
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
each, and none of them has a default:
`peak_guard_active` §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.
`min_spread_eur_kwh` the caller's own round-trip cost threshold
(§10.1's `cycle_cost_threshold_eur`). A verdict
that says "allowed" without being told what
would make a cycle worth doing is a verdict
about arithmetic, not about money. Pass 0.0 to
mean "any positive spread", explicitly.
`uncertainty` the §11.4 doubts the CALLER can see and this
object cannot - today that is
`Uncertainty.NO_WEATHER`. Defaulting it to the
empty set would be this module asserting the
weather forecast is healthy, which is a fact it
has no way to observe. The product's own
real-time uncertainty (§9.2 row 2) is derived
here and unioned in, so AC3 cannot be forgotten
by a caller who passes an empty set.
"""
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 isinstance(min_spread_eur_kwh, bool) or not isinstance(min_spread_eur_kwh, (int, float)) or not math.isfinite(min_spread_eur_kwh) or min_spread_eur_kwh < 0:
raise TypeError(
f"min_spread_eur_kwh must be a finite, non-negative number of "
f"EUR/kWh, not {min_spread_eur_kwh!r}. A negative threshold is "
f"one every spread clears.")
caller_sources = frozenset(uncertainty)
if any(not isinstance(u, Uncertainty) for u in caller_sources):
raise TypeError(
f"uncertainty must be Uncertainty members, not "
f"{sorted(map(repr, caller_sources))} - a string widens by "
f"nothing while reporting that it widened.")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
@@ -765,26 +881,64 @@ class TariffPlan:
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:
# ⚠️ REFUSAL 3, REWRITTEN BY FCST-03 FROM "ANY POINT IS PROVISIONAL" TO
# "THE FIRM POINTS ARE NOT ENOUGH TO TRADE ON", and the difference is
# the whole ticket. §8.3 fills the hours a day-ahead feed has not
# published yet with yesterday's profile and flags them - so from
# midnight until the 14-16 h CET publication, a normal healthy site has
# a series that is PART firm and PART carried. Refusing on "any point
# provisional" would switch arbitrage off every morning of every day on
# every dynamic site, which is not what §8.3 says and is a behaviour
# nothing would ever report. What §8.3 forbids is acting ON a
# provisional price - so the provisional hours are DROPPED from the
# trade set and everything below is computed over the firm ones only.
# A fully provisional series (the §11.4 fallback, or a pre-publication
# day with no fetch at all) has no firm pair left and is refused, which
# is the previous behaviour arrived at by a truer route.
pts = self.read.points
firm = tuple(p for p in pts if not p.provisional)
if len(firm) < 2:
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()
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
f"price - the rest are provisional (origin {self.origin!r}), "
f"and no irreversible action may be taken on a provisional "
f"price. A buy hour and a sell hour must BOTH be firm before a "
f"cycle can be committed. Load shifting, self-consumption and "
f"peak shaving continue")
spread = round(max(p.eur_per_kwh for p in firm)
- min(p.eur_per_kwh for p in firm), 6)
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"the tariff is flat - every one of the {len(firm)} firm hours "
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
f"selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
pts = self.read.points
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
# of active uncertainty sources: the product's own (§9.2 row 2, derived
# from the DECLARED product and never from a plugin id) unioned with
# whatever the caller can see (§11.4's no-weather row).
sources = uncertainty_of(self.read.product) | caller_sources
need = required_spread(min_spread_eur_kwh, sources)
if spread < need:
return Arbitrage.refuse(
f"the firm spread is {spread:.4f} EUR/kWh and this cycle needs "
f"{need:.4f} - {min_spread_eur_kwh:.4f} widened by "
f"{widen_factor(sources):.2f}x because "
f"{', '.join(sorted(u.value for u in sources)) or 'nothing'} "
f"is uncertain (§8.3, §9.2, §11.4). A cycle whose spread does "
f"not cover its own round-trip cost costs money and pack life")
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),
cheapest=min(firm, key=lambda p: p.eur_per_kwh),
dearest=max(firm, key=lambda p: p.eur_per_kwh),
regime=self.read.regime,
product=self.read.product)
product=self.read.product,
required=need,
sources=sources)
@property
def words(self) -> str:
+23 -3
View File
@@ -522,6 +522,7 @@ class Projection:
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 "
@@ -657,19 +658,38 @@ class PeakTracker:
"""
start = averager.block_start
if start is None:
return Projection(0.0, 0.0, 0.0, 0.0, False, 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)
held = max(getattr(averager, "_last_net", 0.0), 0.0)
# ⚠️ 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)
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)
File diff suppressed because it is too large Load Diff
+889
View File
@@ -0,0 +1,889 @@
"""Runnable check for FCST-03 - provisional prices. `python3 test_price_forecast.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
it:
AC1 Every price point carries a `provisional` flag MATCHING ITS TRUE
PROVENANCE - a point filled from yesterday's profile is provisional even
when the number happens to be right, a point the feed published keeps
whatever flag the feed gave it, and an unmarked carry-forward is not
constructible rather than merely discouraged.
AC2 An irreversible action gated on a provisional price is REJECTED, with the
spec's own example - a maintenance-phase transition - actually refused:
the phase machine is still in `idle` afterwards.
AC3 Real-time / imbalance prices get MEASURABLY WIDER thresholds. Measured,
by finding the smallest spread each regime will accept and comparing the
two numbers - not by reading a constant back out of the module that
defines it.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
from the set of hours the fixture published, by different arithmetic than
app/price_forecast.py uses; the DST day lengths come from the offset
difference rather than from a 23 or a 25 typed into a check; the widening
factor is MEASURED by bisecting the accept/refuse boundary and then
compared with the declared one.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, and
this ticket is full of that trap:
- YESTERDAY AND TODAY MUST PRICE EVERY HOUR DIFFERENTLY, or a fill that
carried the wrong day forward is invisible. Asserted first, as a check.
- THE PROVISIONAL HOURS MUST CONTAIN THE CHEAPEST HOUR, or "arbitrage
ignores provisional hours" grades nothing: the firm-only spread and the
whole-series spread would be the same number. Asserted before it is
used, and it is the realistic shape of the danger - yesterday's cheap
hour luring a charge that today's prices do not justify.
- THE TWO WIDENING FACTORS MUST DIFFER, or `max`, `sum` and `product` are
indistinguishable combination rules. Asserted, then all three are
computed and only one matches what the code does.
- THE LOCAL-HOUR FILL AND A NAIVE `t - 24 h` FILL MUST DISAGREE on the
transition days, or the DST check passes for both implementations.
Asserted, then the correct one is graded.
- PRICES ARE WRITTEN TO SIX DECIMALS with distinct 3-, 4- and 6-dp
roundings, so an implementation that rounds differently is visible.
(TARIFF-03's fixtures were 3-dp, where those three are one number.)
* A FAILED READ IS NOT AN OBSERVATION. A missing price is not a zero price
and a stale one is not a fresh one: the gate blocks on `no_data`, blocks on
an hour the series does not cover, and blocks on a provisional hour - three
different reasons, none of them an answer.
"""
import ast
import inspect
import os
import sys
from datetime import datetime, timedelta, timezone
from app import price as _price
from app import price_forecast as _fc
from app import static_tariff as _st
from app.maintenance import (
CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN, DRAIN, IDLE, MaintConfig, Maintenance,
)
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, PROVIDERS, Uncertainty,
WIDEN_FACTOR, brussels, brussels_offset, plan_is_provisional,
required_spread, uncertainty_of, widen_factor,
)
from app.price_forecast import (
MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours,
fill_from_yesterday, price_permits, begin_phase,
)
from app.static_tariff import Arbitrage, TariffPlan
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
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
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."""
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
# --------------------------------------------------------------------------- #
# Fixtures.
#
# ⚠️ SIX-DECIMAL PRICES, DELIBERATELY. TARIFF-03's fixtures were 0.3410 /
# 0.2570, where rounding to 3, 4 and 6 decimals gives the same number - so an
# implementation that rounded differently from the one being graded survived
# every check. These are chosen so those three roundings differ.
# --------------------------------------------------------------------------- #
def y_price(h):
"""Yesterday's all-in EUR/kWh for local hour h."""
return round(0.123437 + 0.010700 * h, 6)
def t_price(h):
"""Today's PUBLISHED all-in EUR/kWh for local hour h. Different from
yesterday's at every hour, on purpose - see the degeneracy note above."""
return round(0.206562 + 0.009300 * h, 6)
def local_midnight(y, m, d) -> datetime:
"""The UTC instant of Brussels local midnight on that date, derived from
`brussels_offset` rather than from a table of dates."""
naive = datetime(y, m, d, 0, 0)
for hours in (1, 2):
guess = naive.replace(tzinfo=UTC) - timedelta(hours=hours)
if brussels_offset(guess).utcoffset(None) == timedelta(hours=hours):
return guess
raise AssertionError(f"no consistent Brussels offset for {naive}")
def hours_between(a, b):
out, u = [], a
while u < b:
out.append(u)
u += timedelta(hours=1)
return out
def series_of(instants, price_of, product, provider, *, provisional):
return PriceRead.series(
[PricePoint(start=brussels(u), eur_per_kwh=price_of(u),
provisional=provisional) for u in instants],
product, provider, instants[0])
# A plain winter day: local 2026-01-15, 24 hours, no transition in sight.
D0 = local_midnight(2026, 1, 15)
D1 = local_midnight(2026, 1, 16)
YEST0 = local_midnight(2026, 1, 14)
DAY = hours_between(D0, D1)
YDAY = hours_between(YEST0, D0)
YESTERDAY = series_of(YDAY, lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
# The feed has published the first 12 hours of today and nothing after - the
# ordinary state of a dynamic site before the 14-16 h CET print (§5.4).
PUBLISHED_N = 12
TODAY_PART = series_of(DAY[:PUBLISHED_N], lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
print()
print("0. the fixtures are not degenerate (checked before anything is graded)")
check("yesterday and today price EVERY shared hour differently, so a fill that "
"carried the wrong day forward would be visible in the value and not "
"only in the flag",
all(y_price(h) != t_price(h) for h in range(24)))
_roundings = derived(lambda: {round(y_price(3), n) for n in (3, 4, 6)})
check(f"...and a fixture price rounds to three DIFFERENT numbers at 3, 4 and 6 "
f"decimals {sorted(_roundings)}, so a divergent rounding cannot hide",
len(_roundings) == 3)
check("...and both widening factors differ, so max / sum / product are three "
"distinguishable answers",
len(set(WIDEN_FACTOR.values())) == len(WIDEN_FACTOR) >= 2)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the flag matches PROVENANCE, and an unmarked fill is not "
"constructible")
FILLED = fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
since=D0, until=D1, stamp=D0)
_pub_starts = {u for u in DAY[:PUBLISHED_N]}
_expected_prov = {u: (u not in _pub_starts) for u in DAY}
_got_prov = derived(lambda: {p.start.astimezone(UTC): p.provisional
for p in FILLED.points})
check(f"the filled series covers every hour of the day "
f"{len(FILLED.points) if FILLED.kind == 'series' else FILLED}",
FILLED.kind == "series" and len(FILLED.points) == len(DAY))
check("...and EVERY point's flag equals what its provenance says it should be, "
"re-derived here from the set of hours the fixture published",
_got_prov == _expected_prov)
_carried = derived(lambda: [p for p in FILLED.points if p.provisional])
check(f"...which is {len(DAY) - PUBLISHED_N} carried hours and {PUBLISHED_N} "
f"published ones, counted rather than asserted",
len(_carried) == len(DAY) - PUBLISHED_N)
check("...and the carried hours carry YESTERDAY's number for the same wall "
"clock hour, not today's and not a neighbour's",
all(near(p.eur_per_kwh, y_price(brussels(p.start).hour))
for p in _carried))
check("...while the published hours carry TODAY's, so the two halves are not "
"the same data wearing two flags",
all(near(p.eur_per_kwh, t_price(brussels(p.start).hour))
for p in FILLED.points if not p.provisional))
# ⚠️ THE COPY IS THE DEFECT. A published point that survives as a COPY is one
# whose flag could have been rewritten on the way through; identity is the only
# property that rules that out for every field at once.
check("a published point is passed through BY IDENTITY - the object the "
"provider produced is the object in the filled series",
all(any(p is q for q in TODAY_PART.points)
for p in FILLED.points if not p.provisional))
check("...and the canonical §9.3 dict carries the flag out to the wire for "
"both kinds",
FILLED.canonical()["prices"][0]["provisional"] is False
and FILLED.canonical()["prices"][-1]["provisional"] is True)
check("...and plan_is_provisional() sees the series as provisional overall, "
"because at least one hour is",
plan_is_provisional(FILLED) is True)
# The audit is a post-condition that RAISES. Feed it the exact defect.
_stale_copy = PricePoint(start=FILLED.points[-1].start,
eur_per_kwh=FILLED.points[-1].eur_per_kwh,
provisional=False) # the copy-the-flag bug
check("the provenance audit REFUSES a carried hour flagged non-provisional - "
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
"meter one layer up",
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
check("...and refuses a published hour that arrived as a COPY rather than as "
"the published object, which is where a flag gets lost",
raises(lambda: _fc._audit(
[PricePoint(start=TODAY_PART.points[0].start,
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
provisional=TODAY_PART.points[0].provisional)],
{D0: TODAY_PART.points[0]}), ValueError))
check("...and never repairs a flag: the audit's only outcomes are silence and "
"an exception, so a series it passed was already correct",
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
for i, u in enumerate(DAY[:PUBLISHED_N])})
is None)
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print
# is settled after the fact and says so (§9.2). "Published" is not "firm".
_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
PriceProduct.REALTIME, "eneco_eddy", provisional=True)
_eddy_filled = derived(lambda: fill_from_yesterday(
_eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
check("a PUBLISHED point that the feed itself flagged provisional keeps that "
"flag - the audit checks provenance, and does not force published hours "
"to False",
_eddy_filled.kind == "series"
and all(p.provisional for p in _eddy_filled.points[:2])
and _eddy_filled.points[0] is _eddy.points[0])
# The structural half: the constructor has nothing to pass, and the file is
# scanned so the pass-through idiom cannot come back looking ordinary.
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
"forget, default, or copy from the point being carried",
"provisional" not in inspect.signature(_fc._carried).parameters)
_SRC = derived(lambda: open(os.path.join(HERE, "app", "price_forecast.py"),
encoding="utf-8").read())
def _nonliteral_provisional(src):
"""Every `PricePoint(...)` in the module whose `provisional` is not a
literal. AST, not a substring: the module docstring quotes the defect it
exists to prevent, and a text scan would match its own documentation."""
out = []
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.Call) and \
getattr(node.func, "id", None) == "PricePoint":
kw = {k.arg: k.value for k in node.keywords}
v = kw.get("provisional")
if not isinstance(v, ast.Constant):
out.append(ast.dump(node)[:80])
return out
_leaks = derived(lambda: _nonliteral_provisional(_SRC))
check(f"no PricePoint built in app/price_forecast.py takes its `provisional` "
f"from an expression - it is a literal at every construction site, so "
f"`provisional=y.provisional` cannot reappear in a later edit {_leaks}",
_leaks == [])
# The scan must be able to fail, or it grades nothing.
check("...and that scan FAILS on the defect it names, so it is a check and not "
"a decoration",
_nonliteral_provisional(
"PricePoint(start=t, eur_per_kwh=v, provisional=y.provisional)") != [])
# --------------------------------------------------------------------------- #
print()
print("2. the fill keys on the WALL CLOCK, so the 23- and 25-hour days are right")
for label, (y, m, d) in (("spring forward (23 h)", (2026, 3, 29)),
("fall back (25 h)", (2026, 10, 25))):
a = local_midnight(y, m, d)
b = local_midnight(y, m, d + 1)
yb = local_midnight(y, m, d - 1)
yesterday = series_of(hours_between(yb, a),
lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
# Nothing published at all yet: the whole day is carried forward.
nothing = PriceRead.no_data("the day-ahead print has not landed yet")
got = derived(lambda: fill_from_yesterday(nothing, yesterday=yesterday,
since=a, until=b, stamp=a))
# ⚠️ DERIVED FROM THE OFFSETS, not from a 23 or a 25 typed here.
off_a = brussels_offset(a).utcoffset(None).total_seconds() / 3600
off_b = brussels_offset(b - timedelta(seconds=1)).utcoffset(None) \
.total_seconds() / 3600
expect = int(24 - (off_b - off_a))
check(f"{label}: the day is {expect} hours long, derived from the offset "
f"change {off_a:+.0f} -> {off_b:+.0f}, and the fill emits exactly "
f"that many",
got.kind == "series" and len(got.points) == expect)
check(f"{label}: every hour of it is flagged provisional - nothing was "
f"published",
got.kind == "series" and all(p.provisional for p in got.points))
# The discriminating part: a naive `t - 24 h` fill would give a DIFFERENT
# answer here, so this check separates the two implementations.
y_by_utc = {u: y_price(brussels(u).hour) for u in hours_between(yb, a)}
naive = [y_by_utc.get(u - timedelta(hours=24)) for u in hours_between(a, b)]
wall = [y_price(brussels(u).hour) for u in hours_between(a, b)]
check(f"{label}: the wall-clock fill and a naive t-24h fill DISAGREE, so "
f"the check below can tell them apart",
naive != wall)
check(f"{label}: ...and the emitted values are the WALL-CLOCK ones",
got.kind == "series"
and [round(p.eur_per_kwh, 6) for p in got.points] == wall)
# The 25-hour day's two 02:00 hours are two distinct instants, both filled.
_oct = local_midnight(2026, 10, 25)
_oct_end = local_midnight(2026, 10, 26)
_oct_fill = fill_from_yesterday(
PriceRead.no_data("nothing yet"),
yesterday=series_of(hours_between(local_midnight(2026, 10, 24), _oct),
lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False),
since=_oct, until=_oct_end, stamp=_oct)
_twos = [p for p in _oct_fill.points if brussels(p.start).hour == 2]
check("the 25-hour day has TWO local 02:00 points, at different offsets, and "
"both are filled and both are flagged - a 24-slot implementation would "
"collide them into one",
len(_twos) == 2
and _twos[0].start.utcoffset() != _twos[1].start.utcoffset()
and all(p.provisional for p in _twos))
# --------------------------------------------------------------------------- #
print()
print("3. §5.4 - a late publication REPLACES a carried hour and re-flags it")
_late = fill_from_yesterday(
series_of(DAY, lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False),
yesterday=YESTERDAY, since=D0, until=D1, stamp=D1)
_h20 = DAY[20]
_before = derived(lambda: FILLED.at(_h20))
_after = derived(lambda: _late.at(_h20))
check("before the print, hour 20 is provisional and carries YESTERDAY's number",
_before.provisional is True and near(_before.eur_per_kwh, y_price(20)))
check("...after it, the same hour is FIRM and carries today's - the flag and "
"the number move together, because the series is rebuilt and not patched",
_after.provisional is False and near(_after.eur_per_kwh, t_price(20)))
check("...and the two numbers actually differ, so 'it was replaced' is a "
"measurement and not a restatement",
not near(_before.eur_per_kwh, _after.eur_per_kwh))
check("...and re-running the fill over an already-complete series changes "
"nothing: it is idempotent",
[p.canonical() for p in fill_from_yesterday(
_late, yesterday=YESTERDAY, since=D0, until=D1, stamp=D1).points]
== [p.canonical() for p in _late.points])
# Nothing to fill from, and nothing published: no_data, never an empty series.
_nothing = derived(lambda: fill_from_yesterday(
PriceRead.no_data("the feed timed out"),
yesterday=PriceRead.no_data("and there is no yesterday either"),
since=D0, until=D1, stamp=D0))
check("with neither a published hour nor a yesterday, the result is no_data - "
"not an empty series, and not a series of zeros",
_nothing.kind == "no_data")
check("...so it has no .points to iterate and no .get() to default out of",
raises(lambda: _nothing.points, Absence)
and raises(lambda: _nothing.get("points", []), Absence))
check("...and its reason names §11.4 for an operator",
"§11.4" in _nothing.reason)
check("a naive `since` is refused rather than silently interpreted as UTC",
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
since=datetime(2026, 1, 15),
until=D1, stamp=D0), ValueError))
check("...and `yesterday` has no default, so a fill with nothing to fill from "
"cannot be written by accident",
raises(lambda: fill_from_yesterday(TODAY_PART, since=D0, until=D1,
stamp=D0), TypeError))
# --------------------------------------------------------------------------- #
print()
print("4. AC2 - an irreversible action is REJECTED on a provisional price")
class FakeStore:
"""Same surface as app/store.Store, no disk. Copied from test_maintenance."""
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def save(self):
pass
def maint():
return Maintenance(MaintConfig(enabled=True), FakeStore())
check("the drain's commitment window is DERIVED from the phase machine's own "
"timeout, so the gate and maintenance.py cannot drift apart",
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
# schedule runs into the evening - hours a morning-only day-ahead print has not
# covered. That is the ticket's scenario and it is where the window is placed.
START = D0 + timedelta(hours=MaintConfig().start_hour)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
len(_hours) == int(DRAIN_TIMEOUT_MIN / 60))
check("an action that commits nothing is not an irreversible one and cannot be "
"declared", raises(lambda: Irreversible("X", 0), ValueError)
and raises(lambda: Irreversible("", 4), ValueError))
# The stimulus: the first hours are firm, the drain's window reaches into the
# carried ones. THIS is the ticket's scenario - a cycle started at 10:00 on
# prices that were published for the morning and guessed for the evening.
_m = maint()
_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN))
_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional))
check("the drain's window really does reach past the published hours while "
"staying INSIDE the series, so the refusal below is about provenance and "
"not about a short series",
FILLED.covers(_hours[-1])
and any(not FILLED.at(u).provisional for u in _hours)
and any(FILLED.at(u).provisional for u in _hours))
check("a maintenance DRAIN whose window includes a carried-forward hour is "
"BLOCKED", _gate_bad.kind == "blocked")
check("...and the phase machine did not move: it is still idle, so the "
"rejection is of the ACTION and not merely of a verdict object",
_m.phase == IDLE)
check("...and the reason names §8.3, the offending hour, and when the real "
"price is expected",
"§8.3" in _gate_bad.reason
and _first_prov.isoformat() in _gate_bad.reason
and "14-16" in _gate_bad.reason)
# The positive arm, so the block above is discriminating rather than universal.
_firm_long = series_of(hours_between(START,
START + timedelta(hours=len(_hours) + 2)),
lambda u: t_price(brussels(u).hour % 24),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
_m2 = maint()
_gate_ok = derived(lambda: begin_phase(_m2, START, _firm_long,
MAINTENANCE_DRAIN))
check("...while the SAME transition on a fully published series is PERMITTED, "
"and the phase machine actually moves to drain",
_gate_ok.kind == "permitted" and _m2.phase == DRAIN)
check("...and the permitting arm carries the evidence it checked - one firm "
"point per committed hour",
_gate_ok.kind == "permitted" and len(_gate_ok.hours) == len(_hours)
and not any(p.provisional for p in _gate_ok.hours))
# The other two ways a price can fail to be an observation.
_m3 = maint()
_gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"),
MAINTENANCE_DRAIN)
check("no price data at all BLOCKS an irreversible action too - a missing "
"price is not a cheap one (§11.4)",
_gate_none.kind == "blocked" and "§11.4" in _gate_none.reason
and _m3.phase == IDLE)
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
_gate_short = price_permits(_short, D0, MAINTENANCE_DRAIN)
check("...and so does an hour the series does not cover, which is §11.4 for "
"that hour rather than a reason to reach for a neighbour",
_gate_short.kind == "blocked" and "no price covers" in _gate_short.reason)
# The variant type itself, held to the same shape as PriceRead and Arbitrage.
check("a BLOCKED gate carries a reason and nothing else: .hours and .action "
"raise Absence rather than answering with an empty tuple",
raises(lambda: _gate_bad.hours, Absence)
and raises(lambda: _gate_bad.action, Absence))
check("...it is not iterable and has no .get(), the other two routes to an "
"empty, harmless-looking loop",
raises(lambda: list(_gate_bad), Absence)
and raises(lambda: _gate_bad.get("hours", []), Absence))
check("...match() demands BOTH arms, so the block cannot be fallen through",
raises(lambda: _gate_bad.match(permitted=lambda g: 1), TypeError))
check("...and there is no falsy variant, so `if gate:` cannot look like a "
"guard while being none",
bool(_gate_bad) is True and bool(_gate_ok) is True)
# A genuinely carried point - the flagged one, not the mis-flagged copy above.
_prov_pt = FILLED.points[-1]
check("the point the two constructor checks below are handed really is "
"provisional, or they would be grading nothing",
_prov_pt.provisional is True)
check("a permitted gate cannot be built over a provisional hour or over no "
"hours at all - §8.3 is the one rule this type has",
raises(lambda: Gate.permit(MAINTENANCE_DRAIN, [_prov_pt]), ValueError)
and raises(lambda: Gate.permit(MAINTENANCE_DRAIN, []), ValueError))
# TEST-14, PORTED. A default on any of these constructors reopens the exact
# hole `_NoData.reason` had - and static_tariff's `_Refused.reason` still has,
# because nothing scanned it. Scanned by SIGNATURE, so a default added later is
# red without anyone remembering to write a new case.
def _defaulted(fn):
try:
ps = list(inspect.signature(fn).parameters.values())
except (TypeError, ValueError) as e: # pragma: no cover
return _Raised(e)
return [q.name for q in ps
if q.name != "self" and q.default is not inspect.Parameter.empty]
_ctors = {"Gate.permit": Gate.permit, "Gate.block": Gate.block,
"Gate.match": Gate.match,
"_Permitted.__init__": _fc._Permitted.__init__,
"_Blocked.__init__": _fc._Blocked.__init__,
# ⚠️ static_tariff's outcome type is scanned HERE because nothing
# scanned it before: TEST-14 records `_Refused.reason` gaining a
# default as a mutation that survived every check in that file.
"Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse,
"Arbitrage.match": Arbitrage.match,
"_Allowed.__init__": _st._Allowed.__init__,
"_Refused.__init__": _st._Refused.__init__}
_with_defaults = derived(lambda: {n: d for n, d in
((n, _defaulted(f)) for n, f in _ctors.items())
if d})
check(f"no constructor or handler of an outcome type - Gate's OR Arbitrage's - "
f"carries a DEFAULT, so a verdict can never be built out of nothing "
f"{_with_defaults}", _with_defaults == {})
check("...so a block with no reason is a TypeError at the call site, not a "
"blank status line",
raises(lambda: Gate.block(), TypeError)
and raises(lambda: Gate.block(""), ValueError))
check("...and the same holds for the refusal FCST-03 added to Arbitrage",
raises(lambda: Arbitrage.refuse(), TypeError))
# ⚠️ ONE CALL PER ROLE, AND THE FIRST DRAFT SHOWED WHY. Passing the provisional
# point as BOTH `cheapest` and `dearest` is a stimulus that cannot distinguish
# "the constructor guards the cheapest hour" from "the constructor guards both
# hours": a guard on either one alone raises, and the check goes green for an
# implementation that leaves the sell hour unguarded. That is the degeneracy
# rule this file states at the top, in a new dress - so the two roles are
# stimulated SEPARATELY, each with a firm point in the other seat.
_firm_pt = TODAY_PART.points[0]
check("the two points these constructor checks use really differ in provenance, "
"or neither of them grades anything",
_prov_pt.provisional is True and _firm_pt.provisional is False)
check("an ALLOWED arbitrage verdict cannot be constructed with a provisional "
"CHEAPEST hour - the buy hour - even directly: §8.3 is enforced at the "
"constructor, not only in the ladder above it",
raises(lambda: Arbitrage.allow(0.1, _prov_pt, _firm_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
ValueError))
check("...nor with a provisional DEAREST hour - the sell hour - which a guard "
"written for `cheapest` alone would have let through",
raises(lambda: Arbitrage.allow(0.1, _firm_pt, _prov_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
ValueError))
check("...while two firm points build the verdict perfectly well, so the two "
"refusals above are discriminating rather than universal",
Arbitrage.allow(0.1, _firm_pt, _firm_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()).kind
== "allowed")
# --------------------------------------------------------------------------- #
print()
print("5. AC3 - real-time thresholds are MEASURABLY wider (measured, not read)")
def plan_with_spread(spread, product, provider):
"""A two-hour series with exactly `spread` between its two prices."""
lo, hi = 0.150000, round(0.150000 + spread, 6)
pts = [PricePoint(start=brussels(D0), eur_per_kwh=lo, provisional=False),
PricePoint(start=brussels(D0 + timedelta(hours=1)),
eur_per_kwh=hi, provisional=False)]
return TariffPlan(PriceRead.series(pts, product, provider, D0), None,
"provider")
def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
"""The SMALLEST spread this configuration will trade on, found by bisecting
the accept/refuse boundary of the real verdict. Nothing here reads a
factor out of the module it is grading."""
lo, hi = 0.0, 4.0
for _ in range(60):
mid = (lo + hi) / 2
v = plan_with_spread(mid, product, provider).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=base,
uncertainty=sources)
if v.kind == "allowed":
hi = mid
else:
lo = mid
return hi
BASE = 0.104000 # a caller's own round-trip cost threshold, EUR/kWh
_normal = derived(lambda: min_accepted_spread(PriceProduct.DAY_AHEAD,
frozenset(), BASE))
_wide = derived(lambda: min_accepted_spread(PriceProduct.REALTIME,
frozenset(), BASE))
_weather = derived(lambda: min_accepted_spread(
PriceProduct.DAY_AHEAD, frozenset({Uncertainty.NO_WEATHER}), BASE))
_both = derived(lambda: min_accepted_spread(
PriceProduct.REALTIME, frozenset({Uncertainty.NO_WEATHER}), BASE))
check(f"a day-ahead product trades on any spread above its own threshold "
f"{_normal:.6f}, which is the base {BASE} it was given",
near(_normal, BASE, 1e-6))
check(f"a REAL-TIME / imbalance product needs {_wide:.6f} - MEASURABLY wider, "
f"by a factor of {(_wide / _normal) if _normal else 0:.3f} measured from "
f"two verdict boundaries and not read from any constant",
_wide > _normal + 1e-6)
check(f"...and that measured factor equals the declared one for "
f"REALTIME_PRODUCT ({WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]}), so "
f"the declaration and the behaviour are the same thing",
near(_wide / _normal, WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT], 1e-4))
check(f"§11.4's no-weather row reaches the SAME widening path by a different "
f"trigger, and lands at {_weather:.6f}",
near(_weather / _normal, WIDEN_FACTOR[Uncertainty.NO_WEATHER], 1e-4))
# ⚠️ THE COMBINATION RULE, ON A STIMULUS WHERE THE THREE CANDIDATES DIFFER.
_f1 = WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]
_f2 = WIDEN_FACTOR[Uncertainty.NO_WEATHER]
check(f"with BOTH sources active the threshold is max(f1,f2)={max(_f1, _f2)} x "
f"base and NOT the product ({_f1 * _f2}) or the sum ({_f1 + _f2}) - two "
f"independent doubts must not compound into a threshold nothing can "
f"clear",
near(_both / _normal, max(_f1, _f2), 1e-4)
and not near(_both / _normal, _f1 * _f2, 1e-3)
and not near(_both / _normal, _f1 + _f2, 1e-3))
# The regime must follow the DECLARED product, never the plugin id.
_wide_named_dayahead = derived(lambda: min_accepted_spread(
PriceProduct.REALTIME, frozenset(), BASE, provider="generic_dayahead"))
_normal_named_eddy = derived(lambda: min_accepted_spread(
PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy"))
check("the widening follows the DECLARED PriceProduct even when the provider "
"is called 'generic_dayahead', and does NOT follow the name "
"'eneco_eddy' when the declared product is day-ahead - so a sixth "
"supplier is widened by its declaration and not by an id in a list",
near(_wide_named_dayahead, _wide, 1e-9)
and near(_normal_named_eddy, _normal, 1e-9))
check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, "
"so the mechanism is pointed at the real product",
PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME
and uncertainty_of(PriceProduct.REALTIME)
== frozenset({Uncertainty.REALTIME_PRODUCT})
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset())
check("a string in the uncertainty set is REFUSED rather than widening by 1.0 "
"and reporting that it widened",
raises(lambda: widen_factor({"realtime"}), TypeError)
and raises(lambda: required_spread(0.1, {"realtime"}), TypeError))
check("...and a negative or non-finite base threshold is refused, since it is "
"one every spread clears",
raises(lambda: required_spread(-0.01, frozenset()), ValueError)
and raises(lambda: required_spread(float("nan"), frozenset()), ValueError))
check("required_spread() with nothing uncertain is the base itself, so the "
"widening is additive machinery and not a permanent surcharge",
near(required_spread(BASE, frozenset()), BASE))
# --------------------------------------------------------------------------- #
print()
print("6. the refusal ladder - provisional hours are DROPPED, not traded on")
# ⚠️ THE PROVISIONAL HOURS CARRY THE CHEAPEST PRICE. That is the realistic
# danger (yesterday's cheap night luring a charge) AND the non-degenerate
# stimulus: the firm-only spread and the whole-series spread differ, so a check
# on the spread can tell which set was used.
_cheap_stale = [PricePoint(start=brussels(DAY[i]),
eur_per_kwh=0.041250, provisional=True)
for i in range(3)]
_firm_rest = [PricePoint(start=brussels(DAY[i]),
eur_per_kwh=round(0.212500 + 0.013750 * i, 6),
provisional=False) for i in range(3, 12)]
_mixed = TariffPlan(PriceRead.series(_cheap_stale + _firm_rest,
PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None, "provider")
_all_spread = derived(lambda: _mixed.spread_eur_kwh())
_firm_spread = derived(lambda: round(
max(p.eur_per_kwh for p in _firm_rest)
- min(p.eur_per_kwh for p in _firm_rest), 6))
check(f"the fixture is non-degenerate: the whole-series spread "
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
f"different numbers, so a verdict can be traced to one of them",
not near(_all_spread, _firm_spread, 1e-6))
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
"a provisional price, not trading at all until 16:00",
_v.kind == "allowed")
check(f"...on the FIRM spread {_firm_spread:.6f}, not the one yesterday's "
f"carried-forward cheap hour would have invented",
near(_v.spread_eur_kwh, _firm_spread, 1e-9))
check("...and the cheapest and dearest hours it hands an optimiser are both "
"PUBLISHED hours - the two points ARE the trade",
_v.cheapest.provisional is False and _v.dearest.provisional is False)
check("...and it reports the threshold it cleared and why, rather than leaving "
"OPT-04 to re-derive it from a product enum",
near(_v.required_spread_eur_kwh, 0.0) and _v.uncertainty == frozenset())
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None,
"fallback")
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
"print - is still refused, and the reason counts the firm hours",
_vp.kind == "refused" and "0 of 3" in _vp.reason and "§8.3" in _vp.reason)
check("...and the refusal carries no numbers to trade on",
raises(lambda: _vp.spread_eur_kwh, Absence)
and raises(lambda: _vp.cheapest, Absence))
_one_firm = TariffPlan(
PriceRead.series(_cheap_stale + _firm_rest[:1], PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None, "provider")
check("one firm hour is not enough either: a buy hour and a sell hour must "
"BOTH be published before a cycle can be committed",
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("§10.1 still holds above all of it: the peak guard refuses the same "
"series that is otherwise allowed",
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("the three questions cannot be skipped: all of peak_guard_active, "
"min_spread_eur_kwh and uncertainty are required and keyword-only",
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0), TypeError)
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
check("...nor answered with a truthy 'unknown' or a negative threshold",
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=-0.1,
uncertainty=frozenset()), TypeError))
# The cheap-window flag is the other consumer of provisionality, and it is the
# one a live install's maintenance charge floor already reads.
_firm_hour = _firm_rest[0].start
_prov_hour = _cheap_stale[0].start
check("the cheap-window flag is decided per HOUR: a provisional hour is never "
"cheap, whatever its (stale) number says",
_mixed.cheap_now(_prov_hour) is False)
check("...while a firm hour below the firm mean still is, so carry-forward "
"filling does not switch the maintenance charge floor off every morning",
_mixed.cheap_now(_firm_hour) is True)
# ⚠️ GRADED DIRECTLY, BECAUSE ITS CONSUMERS CANNOT GRADE IT. The §11.4 refusal
# and `cheap_now` both guard provenance independently, so `firm_points()`
# returning the carried hours TOO still leaves both of them answering correctly
# - a stimulus that cannot distinguish an over-reporting accessor from a
# correct one. So the tuple itself is compared with the published points, by
# identity and in order, rather than only the mean it feeds.
check("firm_points() returns EXACTLY the published points, in order - not the "
"carried ones as well, which its two consumers would have absorbed "
"without either of them changing its answer",
list(_mixed.firm_points()) == _firm_rest
and all(a is b for a, b in zip(_mixed.firm_points(), _firm_rest)))
check("...and the firm mean it compares against is derived from published "
"hours only",
near(sum(p.eur_per_kwh for p in _mixed.firm_points())
/ len(_mixed.firm_points()),
sum(p.eur_per_kwh for p in _firm_rest) / len(_firm_rest)))
# --------------------------------------------------------------------------- #
print()
print("7. CFG-03 and validate.py rule 7 - what this ticket did NOT widen")
_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS))
_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
encoding="utf-8").read())
check(f"FCST-03 added ZERO option keys: the in-code mirror still holds exactly "
f"TARIFF-02's and TARIFF-03's {_defaults}",
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
"price_taxes_eur_kwh", "export_price_eur_kwh",
"price_csv_path", "price_feed_url",
"price_horizon_h", "price_timeout_s",
"static_profile"]))
check("...and every one of them is still in config.yaml, which stays the "
"source of truth",
isinstance(_cfg, str)
and all(f"\n {k}:" in _cfg for k in _price.PRICE_OPTION_DEFAULTS))
def _names(path):
try:
tree = ast.parse(open(path, encoding="utf-8").read())
except (OSError, SyntaxError): # pragma: no cover
return set()
out = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
out |= {a.name for a in node.names}
if isinstance(node, ast.Name):
out.add(node.id)
if isinstance(node, ast.Attribute):
out.add(node.attr)
return out
_FC = os.path.join(HERE, "app", "price_forecast.py")
_used = derived(lambda: _names(_FC))
check("app/price_forecast.py reads NO options at all - it takes a PriceRead "
"and instants, so there is no fourth place for an option default to live",
isinstance(_used, set)
and not ({"price_option", "PRICE_OPTION_DEFAULTS", "PluginOptions"}
& _used))
# ⚠️ RULE 7'S TRIPWIRE, RESPECTED RATHER THAN TRIPPED. test_price.py section 12
# pins the set of modules that consume `load_prices()` to exactly
# ["static_tariff.py"], and goes red when a second one appears. This module
# deliberately does NOT fetch: it transforms a read somebody else obtained, so
# rule 7's message about what a configured provider does or does not drive is
# unchanged by this ticket and needed no rewording.
check("...and it does not consume load_prices(), so validate.py rule 7's "
"pinned consumer list is still true and its wording still accurate",
"load_prices" not in _used)
print()
print("FAILED" if fails else "OK")
for f in fails:
print(" -", f)
sys.exit(1 if fails else 0)
+10 -10
View File
@@ -306,8 +306,8 @@ 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)
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
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",
@@ -327,7 +327,7 @@ _flat_dayahead = TariffPlan(
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")
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).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 "
@@ -352,9 +352,9 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a "
# §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")
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).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)
@@ -497,7 +497,7 @@ 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"
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).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"))
@@ -746,12 +746,12 @@ 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"
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).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")
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).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)))
@@ -779,8 +779,8 @@ check("a live feed is used in preference to the static profile",
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,
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
0.023, 1e-9))