Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cd7849e4 | ||
|
|
e7c95bfd8a | ||
|
|
dd40c56ab4 | ||
|
|
c2ed509565 | ||
|
|
2d570d84ce | ||
|
|
cf5094c608 | ||
|
|
9f8b721a4d | ||
|
|
616b62d3e9 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"))
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user