Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d57122ec74 | ||
|
|
4601682815 | ||
|
|
9234016419 | ||
|
|
b7272169b1 | ||
|
|
2b8b8cb97f | ||
|
|
8e589fee3f | ||
|
|
bdd7f18a54 | ||
|
|
a1376e44e4 | ||
|
|
e709554857 | ||
|
|
a30bde1ab1 | ||
|
|
71cd7849e4 | ||
|
|
e7c95bfd8a | ||
|
|
dd40c56ab4 |
@@ -2,6 +2,238 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
**OPT-04's AC4 is met: twelve months of real EPEX BE day-ahead prices are now in
|
||||||
|
the repository, and the strategy has been replayed against them (DATA-02).**
|
||||||
|
AC4 asked for "net-positive across 12 months of replayed prices". OPT-04 shipped
|
||||||
|
it as NOT MET and replayed a synthetic year instead, because `scenarios/` held
|
||||||
|
only P1 meter captures. That was the right call at the time and the wrong
|
||||||
|
diagnosis of why: the criterion was being carried alongside a 30-day *meter*
|
||||||
|
criterion as though both needed waiting, and EPEX BE day-ahead is **public data,
|
||||||
|
not a capture**. It needed a fetch, not a month.
|
||||||
|
|
||||||
|
`scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` — **8760 hourly points**,
|
||||||
|
2025-09-01 to 2026-09-01 Brussels-local, in §9.3's canonical three-field shape.
|
||||||
|
`scenarios/fetch_epex_be.py` produced it and `--verify` re-derives every claim it
|
||||||
|
makes about itself without touching the network.
|
||||||
|
|
||||||
|
**Licensing was checked before anything was committed.** The source is
|
||||||
|
Fraunhofer ISE's Energy-Charts API, which publishes the **BE** zone under
|
||||||
|
**CC BY 4.0 from Bundesnetzagentur | SMARD.de** and other zones under
|
||||||
|
private-use-only terms — so this zone is redistributable with attribution and the
|
||||||
|
data is committed rather than left as a script plus a checksum. The fetcher
|
||||||
|
**refuses to write a file** whose per-response `license_info` stops saying
|
||||||
|
CC BY 4.0. Cross-checked against the upstream: SMARD's own `4996/DE` series
|
||||||
|
matched this feed on all 96 quarter-hours of 2026-04-10, maximum difference
|
||||||
|
0.00 EUR/MWh.
|
||||||
|
|
||||||
|
**The figures are raw wholesale and the file says so.** `"all_in": false`, with
|
||||||
|
the markup/tax assumption carried in `provenance.all_in_assumption` as *numbers
|
||||||
|
to apply* rather than numbers already applied — TARIFF-02's double-counting trap
|
||||||
|
in data form. `test_arbitrage.py` section 7B applies it in one visible place and
|
||||||
|
**asserts arithmetically** that its two additive terms cancel out of every daily
|
||||||
|
spread, so the trade counts depend on the VAT multiplier alone and not on the
|
||||||
|
guessed markup. The same replay is run on raw wholesale as well, so how much of
|
||||||
|
the answer rests on the guess is reported (110 trades all-in, 88 raw).
|
||||||
|
|
||||||
|
**DST is handled by construction, not by a special case.** The fetcher iterates
|
||||||
|
in UTC and renders through `price.brussels()`, so **2025-10-26 comes out 25
|
||||||
|
points and 2026-03-29 comes out 23** — verified by counting per Brussels day
|
||||||
|
rather than asserting 24, and by checking that October's 02:00 appears twice at
|
||||||
|
`+02:00` and `+01:00` with two different prices while March's 02:00 does not
|
||||||
|
appear at all. The window is bounded at *local* midnight: bounding it at 00:00
|
||||||
|
UTC produced a 22-hour first day, and a 24-hour day silently missing from the
|
||||||
|
end, both of which the point-and-gap accounting caught.
|
||||||
|
|
||||||
|
**The result, at the shipped default, whatever it is.** At
|
||||||
|
`cycle_cost_threshold_eur` **0.10** widened by FCST-03's no-weather **1.5×** to a
|
||||||
|
required 0.1500 EUR/kWh, the real year completes **110 round trips** and is
|
||||||
|
**net-positive at +24.88 EUR** of benefit net of the cycle cost (2 kWh a trade).
|
||||||
|
Unwidened it completes 173. **Nothing was tuned:** the shipped default and the
|
||||||
|
2.5/1.5 factors are untouched, and every figure comes out of the same `decide()`.
|
||||||
|
|
||||||
|
**What the product owner's open decision now has under it.** The four
|
||||||
|
independent reports that a static day/night spread of ~0.0840 does not clear
|
||||||
|
0.10 **all stand** — a static-tariff site still never trades. What was missing
|
||||||
|
was the other half: a *dynamic* site's daily spread clears the widened
|
||||||
|
requirement on **121 of 365 days**, median daily all-in spread **0.1130**. The
|
||||||
|
threshold at which a median day would clear is **0.0754**, and at that value the
|
||||||
|
same year completes 156 trips for +35.42 EUR. Both numbers are reported; neither
|
||||||
|
is applied.
|
||||||
|
|
||||||
|
**And the synthetic year stays, because the real one cannot grade §17.** Not one
|
||||||
|
of the 365 real days has a negative *dearest* hour — 372 negative wholesale hours
|
||||||
|
and 0 days where the strategy would have sold into one — so "0 discharges into a
|
||||||
|
negative-price hour" over real prices is a count of something that could not
|
||||||
|
happen, and deleting the guard would not change it. Section 7B asserts that
|
||||||
|
degeneracy instead of quietly reporting the zero; section 7's generator is still
|
||||||
|
the only thing here that puts a negative price where the strategy would sell.
|
||||||
|
|
||||||
|
**Size, measured rather than assumed.** Plain JSON: 893 KB in the working tree
|
||||||
|
but **74.6 KB as a git object**, against 56.5 KB for a committed `.json.gz`. Git
|
||||||
|
already compresses blobs, so gzipping saves 18 KB and costs a reviewer the
|
||||||
|
ability to read the provenance block — including the licence claim they are
|
||||||
|
meant to check — in the diff. Plain, and `load()` still accepts `.gz` for a
|
||||||
|
future dataset that earns it.
|
||||||
|
|
||||||
|
**Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch
|
||||||
|
path; nothing under `app/` imports the script, and the dataset is for replay.
|
||||||
|
|
||||||
|
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
|
||||||
|
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
|
||||||
|
and nothing below it consulted the state of charge — `soc_floor` belongs to the
|
||||||
|
maintenance cycle, and freeze.py already recorded 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**. Relying on the BMS is not absurd — it is a real
|
||||||
|
floor and it works — but it is the pack's protection rather than the operator's,
|
||||||
|
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
|
||||||
|
with no EMS-side record of why.
|
||||||
|
|
||||||
|
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
|
||||||
|
defaulted. Above the floor nothing changes. At or below it the guard files a
|
||||||
|
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
|
||||||
|
and no discharge is capped — and reports `floor_limited` with the reason it
|
||||||
|
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
|
||||||
|
and the floor can never be negative, so §9.1's "discharge and export are never
|
||||||
|
capped by the peak guard" survives as a property of the object (swept: 0 negative
|
||||||
|
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
|
||||||
|
|
||||||
|
**Failing to shave is not silent.** A floor-limited cycle raises one notification
|
||||||
|
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
|
||||||
|
the two ask the operator for opposite things, and renders its own status row.
|
||||||
|
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
|
||||||
|
exactly the value that would disarm the floor, and this project has shipped that
|
||||||
|
defaulting once before (`| float(100)` in the stage-5 loop).
|
||||||
|
|
||||||
|
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
|
||||||
|
a modelled pack sized from the day's own measured appetite, so it genuinely
|
||||||
|
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
|
||||||
|
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
|
||||||
|
energy withheld equals the energy between the two floors to within one cycle at
|
||||||
|
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
|
||||||
|
5030 W, because a floor-limited guard shaves less on purpose.
|
||||||
|
|
||||||
|
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
|
||||||
|
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
|
||||||
|
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
|
||||||
|
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
|
||||||
|
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
|
||||||
|
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
|
||||||
|
**not** reused and deliberately **not** renamed: it is a drain target, not a
|
||||||
|
spend limit, and the rename is recorded as a decision to revisit if a third
|
||||||
|
floor ever appears.
|
||||||
|
|
||||||
|
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||||||
|
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||||||
|
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||||||
|
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
|
||||||
|
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
|
||||||
|
add-on that commands the battery against the meter on purpose.
|
||||||
|
|
||||||
|
**It consumes the four cards rather than re-deciding anything.** Whether a
|
||||||
|
trade is permitted at all, on which two hours, and against which widened
|
||||||
|
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
|
||||||
|
cannot be obtained without first stating whether the peak guard is holding. So
|
||||||
|
§9.1's "give peak shaving priority over arbitrage" is honoured
|
||||||
|
**structurally**: there is no code path to a tradeable spread that skips the
|
||||||
|
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
|
||||||
|
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
|
||||||
|
if that interlock were bypassed. Two independent mechanisms, neither
|
||||||
|
re-implemented here.
|
||||||
|
|
||||||
|
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
|
||||||
|
there is no telegram stream, so the peak guard is never evaluated. That third
|
||||||
|
state is carried as `None` and it **refuses**: charging at the rail creates the
|
||||||
|
billed quantity while nothing measures the quarter it lands in. `validate.py`
|
||||||
|
refuses that configuration at startup too, along with arbitrage armed on a site
|
||||||
|
with no price source at all, and a SoC floor at or above the ceiling.
|
||||||
|
|
||||||
|
**§17: never export into a negative price.** The guard is on the hour being
|
||||||
|
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
|
||||||
|
spread that clears any shipped threshold and a dearest hour that is still
|
||||||
|
negative. Buying in a negative hour stays allowed; being paid to charge is the
|
||||||
|
right trade.
|
||||||
|
|
||||||
|
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
|
||||||
|
may open an HTTPS connection, and the control tick is what feeds the ESP32
|
||||||
|
heartbeat - a supplier's TLS timeout there is a supplier able to push the
|
||||||
|
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
|
||||||
|
the tick reads an attribute and an age bound, and a plan whose refresh has
|
||||||
|
stopped answering retires rather than standing as if it were current.
|
||||||
|
|
||||||
|
**What is honestly not measured.** The acceptance criterion asked for
|
||||||
|
net-positivity across 12 months of *replayed* prices. This repository holds no
|
||||||
|
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
|
||||||
|
and is not restated**. What `test_arbitrage.py` section 7 does instead is
|
||||||
|
replay a synthetic year and assert the property that does not need real prices:
|
||||||
|
every executed round trip cleared its own widened requirement, on all of them,
|
||||||
|
across a year containing 522 hours priced below zero and zero discharges into
|
||||||
|
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
|
||||||
|
site **does not trade at all** - reported, not tuned. That number, and the
|
||||||
|
widening factors beside it, are one product decision and it is open.
|
||||||
|
|
||||||
|
**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
|
**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
|
(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,
|
consumer it was built for. Every cycle the guard projects the open quarter,
|
||||||
|
|||||||
+92
-10
@@ -420,7 +420,7 @@ help - see "Why the tuning is what it is".
|
|||||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||||
| `maintenance_soc_target` | 99 | Charge target |
|
| `maintenance_soc_target` | 99 | Charge target |
|
||||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||||
|
|
||||||
@@ -430,6 +430,8 @@ help - see "Why the tuning is what it is".
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||||
| `peak_cap_w` | The site's capacity-tariff target |
|
| `peak_cap_w` | The site's capacity-tariff target |
|
||||||
|
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
|
||||||
|
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||||
|
|
||||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||||
@@ -530,11 +532,46 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
|
|||||||
is the one state where the money will be spent and shedding load is the only
|
is the one state where the money will be spent and shedding load is the only
|
||||||
thing left that changes the bill.
|
thing left that changes the bill.
|
||||||
|
|
||||||
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
|
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
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
|
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||||
rating — every one of them a key that already exists.
|
rating — every one of them a key that already exists.
|
||||||
|
|
||||||
|
#### The energy floor under the forced discharge (`peak_soc_floor`)
|
||||||
|
|
||||||
|
The guard above can **command a discharge**, and until SAFETY-24 nothing in the
|
||||||
|
add-on bounded how far down it took the pack — the BMS was the only backstop.
|
||||||
|
On the 2026-08-20 replay the guard asks for **878 Wh in a day**.
|
||||||
|
|
||||||
|
That is not absurd: the BMS floor is real and it works. But it is *the pack's*
|
||||||
|
protection, not *yours*. A month of aggressive quarters can leave the battery
|
||||||
|
repeatedly parked at that cut-out with no record on this side of why, and the
|
||||||
|
first symptom — a battery that is never available when you want it — looks
|
||||||
|
exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||||
|
|
||||||
|
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||||||
|
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||||||
|
(that can only ever lower your offtake), and no further discharge is asked for.
|
||||||
|
- **It does not stop the ordinary self-consumption loop.** That loop still has no
|
||||||
|
SoC guard of its own. This bounds what the *peak guard* spends for money.
|
||||||
|
- **When it binds you are told**, once per episode, with notification
|
||||||
|
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||||
|
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||||
|
the only thing left that changes the bill. Failing to shave is never silent —
|
||||||
|
the euro figure is the whole point of the guard.
|
||||||
|
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
|
||||||
|
full battery is exactly the value that would disarm this floor.
|
||||||
|
- **`bms_soc_floor` (10 %)** is the pack's own cut-out — register `0x0560` on the
|
||||||
|
reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can
|
||||||
|
**refuse to start** when `peak_soc_floor` is at or below it: such a floor can
|
||||||
|
never be the thing that stops the discharge, and a guard that advertises a
|
||||||
|
backstop it does not have is the one thing this project will not ship. Equal is
|
||||||
|
refused too — equal buys zero margin.
|
||||||
|
|
||||||
|
Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||||
|
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||||
|
smaller, or your quarters are worse, raise it.
|
||||||
|
|
||||||
### The winding-loop alarm (the output clamp)
|
### The winding-loop alarm (the output clamp)
|
||||||
|
|
||||||
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||||
@@ -644,15 +681,16 @@ floor of 60 s that figure is 60 W and only the margin is left.
|
|||||||
|
|
||||||
### Energy price providers
|
### Energy price providers
|
||||||
|
|
||||||
> **No *fetched* price moves energy in 0.5.x.** This release ships the
|
> **A fetched price moves energy only when you switch arbitrage on.** This
|
||||||
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
|
> release ships the five provider plugins, the canonical all-in format they
|
||||||
> and the config that selects one. Turning a provider on fetches, validates and
|
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||||
> publishes prices and changes no charging decision; the arbitrage strategy that
|
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||||
> would use it is a later ticket.
|
> shipped default, turning a provider on still fetches, validates and publishes
|
||||||
|
> prices and changes no charging decision.
|
||||||
>
|
>
|
||||||
> The one exception is `static_profile` below. A static day/night tariff needs
|
> `static_profile` below needs no network at all, so its cheap window is
|
||||||
> no network, so its cheap window is computed on every cycle and does reach the
|
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||||
> maintenance cycle's grid top-up.
|
> whether or not arbitrage is armed.
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -804,6 +842,50 @@ to, and the site simply has no tariff until the feed returns. It is not required
|
|||||||
— the add-on warns rather than refusing — but filling it in is what makes that
|
— the add-on warns rather than refusing — but filling it in is what makes that
|
||||||
fallback work.
|
fallback work.
|
||||||
|
|
||||||
|
### Price arbitrage (`arbitrage_enabled`)
|
||||||
|
|
||||||
|
§10.1 priority 3: charge in the cheapest published hour, discharge in the
|
||||||
|
dearest, **and only when the spread covers what the round trip costs.** Off by
|
||||||
|
default — this is the first strategy in the add-on that commands the battery
|
||||||
|
*against* the meter on purpose, so it is armed deliberately or not at all.
|
||||||
|
|
||||||
|
| option | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `arbitrage_enabled` | `false` by default. Arms the strategy |
|
||||||
|
| `cycle_cost_threshold_eur` | What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded |
|
||||||
|
| `arbitrage_soc_floor` | The reserve arbitrage may not spend, % |
|
||||||
|
| `arbitrage_soc_ceiling` | The headroom it may not overfill, % |
|
||||||
|
|
||||||
|
**What it needs, and what the add-on refuses.** Arbitrage requires a price
|
||||||
|
source (`price_provider` or `static_profile`) and a P1 meter (`meter_source`).
|
||||||
|
Without a price source there is nothing to tell cheap from expensive; without
|
||||||
|
P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above
|
||||||
|
arbitrage — a charge that nothing measures is exactly what that guard exists to
|
||||||
|
prevent. Both combinations are **refused at startup** rather than left looking
|
||||||
|
armed.
|
||||||
|
|
||||||
|
**Peak shaving always wins.** The strategy cannot obtain a spread at all
|
||||||
|
without first answering whether the peak guard is holding, and even if it
|
||||||
|
could, the guard's floor is filed at a higher arbiter priority and clamps an
|
||||||
|
arbitrage charge. Discharging is never capped by it — only offtake is billed.
|
||||||
|
|
||||||
|
**It never exports into a negative price** (§17). A negative hour is the best
|
||||||
|
possible hour to *charge* in and that is still allowed; selling into one gives
|
||||||
|
energy away and pays for the privilege.
|
||||||
|
|
||||||
|
**At the shipped threshold a static day/night site does not trade.** The
|
||||||
|
realistic Belgian day/night spread is about 0.08 EUR/kWh and the default
|
||||||
|
threshold is 0.10, so the honest answer on such a site is "no opportunity", and
|
||||||
|
the status page says so with the numbers. Whether 0.10 is the right default is
|
||||||
|
a product decision, not a bug: lower it only if you know what a cycle costs
|
||||||
|
your pack.
|
||||||
|
|
||||||
|
**The prices are fetched in the background**, never on the control tick — a
|
||||||
|
supplier's TLS timeout on that path would be a supplier able to push the add-on
|
||||||
|
towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||||||
|
retires itself if the refresh stops answering, at which point arbitrage stops
|
||||||
|
with it.
|
||||||
|
|
||||||
### Site
|
### Site
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
|||||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||||
|
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||||
|
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||||
|
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
|
||||||
|
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
|
||||||
|
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
|
||||||
|
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
|
||||||
|
# because trading against the meter is the entire point - the grid-following
|
||||||
|
# loop would otherwise cancel every charge the moment it created import.
|
||||||
|
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
||||||
P_LOOP = 10 # ordinary grid-following
|
P_LOOP = 10 # ordinary grid-following
|
||||||
|
|
||||||
SET = "set"
|
SET = "set"
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
"""OPT-04 - §10.1 priority 3: charge cheap, discharge expensive, and only when
|
||||||
|
the round trip is worth more than it costs.
|
||||||
|
|
||||||
|
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE CARD SAYS. The functional notes
|
||||||
|
on the Trello card reference specs.md §2.2's `ems/optimizer/...` layout. That
|
||||||
|
module layout does not exist and is not being built; the product owner's
|
||||||
|
decision of 2026-08-30 is that everything ships in `goodwe_controller/app/`,
|
||||||
|
beside price.py, static_tariff.py, peakguard.py and arbiter.py. The BEHAVIOUR
|
||||||
|
§10.1 / §9.1 / §8.3 / §17 describes is unchanged and still authoritative.
|
||||||
|
|
||||||
|
|
||||||
|
WHAT THIS MODULE IS, AND - MORE IMPORTANTLY - WHAT IT IS NOT
|
||||||
|
============================================================
|
||||||
|
Four cards landed before this one and this one CONSUMES all four. Almost every
|
||||||
|
question an arbitrage strategy has to answer is already answered somewhere
|
||||||
|
else, by an object built for it, and the single biggest way to get this ticket
|
||||||
|
wrong is to answer any of them again here:
|
||||||
|
|
||||||
|
price.py (TARIFF-02) what a price IS, all-in, and what a product is.
|
||||||
|
`Uncertainty`, `WIDEN_FACTOR`, `required_spread`.
|
||||||
|
static_tariff.py (TARIFF-03) `TariffPlan.arbitrage()` - THE VERDICT. Whether
|
||||||
|
a trade is permitted at all, on which two hours,
|
||||||
|
against which widened threshold. Five refusal
|
||||||
|
reasons live there, not here.
|
||||||
|
price_forecast.py (FCST-03) provisional carrying, and the rule that no
|
||||||
|
irreversible action rests on a provisional
|
||||||
|
price (§8.3).
|
||||||
|
peakguard.py (SAFETY-07) the capacity-tariff floor, filed as a `lo`
|
||||||
|
LIMIT at P_PEAK on the arbiter.
|
||||||
|
|
||||||
|
So what is left for this file is genuinely small, and that is the point:
|
||||||
|
|
||||||
|
* WHICH HOUR IS IT NOW - is this instant the buy hour, the sell hour, or
|
||||||
|
neither.
|
||||||
|
* MAY THE BATTERY DO IT - state of charge, and §17's negative-price rule.
|
||||||
|
* HOW HARD - one number, filed as one arbiter claim.
|
||||||
|
|
||||||
|
Everything else is delegated, and delegated by CONSTRUCTION rather than by
|
||||||
|
convention: `TariffPlan.arbitrage()` will not hand over a spread at all unless
|
||||||
|
it is told whether the peak guard is holding, what a cycle costs, and what is
|
||||||
|
uncertain. There is no code path here that can reach a tradeable number without
|
||||||
|
having answered §10.1's priority ordering first.
|
||||||
|
|
||||||
|
|
||||||
|
AC3 - PEAK SHAVING WINS, AND IT WINS TWICE
|
||||||
|
==========================================
|
||||||
|
§9.1: "Give peak shaving priority over arbitrage whenever the two conflict."
|
||||||
|
That is honoured through the two mechanisms that already exist, and NEITHER of
|
||||||
|
them is re-implemented here:
|
||||||
|
|
||||||
|
1. THE INTERLOCK. `plan.arbitrage(peak_guard_active=...)` is keyword-only and
|
||||||
|
REQUIRED. When the guard is engaged the verdict is a refusal carrying the
|
||||||
|
§10.1 wording, so `decide()` below never sees a spread at all. It is not
|
||||||
|
possible to write a caller here that skips the question - the argument has
|
||||||
|
no default and the spread is unreachable without it.
|
||||||
|
|
||||||
|
2. THE ARBITER. Even if (1) were somehow bypassed, `claim()` files a SET at
|
||||||
|
P_ARBITRAGE (30) and peakguard files a LIMIT at P_PEAK (50). arbiter.py
|
||||||
|
clause 2 applies every limit whose priority is >= the winning set's, most
|
||||||
|
restrictive wins - so the peak floor CLAMPS an arbitrage charge whatever
|
||||||
|
this module thinks. 50 >= 30 is what makes that true, and it is why the
|
||||||
|
priority below sits under P_PEAK rather than beside it.
|
||||||
|
|
||||||
|
Two independent mechanisms for one rule is deliberate. The first is the one
|
||||||
|
that reports honestly; the second is the one that holds when the first is
|
||||||
|
wrong.
|
||||||
|
|
||||||
|
⚠️ AND THE GUARD MUST BE ABLE TO SEE. On `meter_source: off` there is no
|
||||||
|
telegram stream, so peakguard.decide() is never run and `peak_guard_active` is
|
||||||
|
not FALSE - it is UNKNOWN. Charging a battery at max_w creates offtake, offtake
|
||||||
|
is the billed quantity (§9.1), and doing it deliberately while nothing measures
|
||||||
|
the quarter is the exact shape of failure §9.1 exists to prevent. So the third
|
||||||
|
state is carried explicitly as `None` and it REFUSES. A failed read is not an
|
||||||
|
observation, and "the guard did not run" is not "the guard is clear".
|
||||||
|
|
||||||
|
|
||||||
|
§17's NEGATIVE-PRICE CASE, STATED ONCE
|
||||||
|
======================================
|
||||||
|
"Negative-price hours - arbitrage must not export." A negative price means the
|
||||||
|
market pays the site to consume. Discharging the battery into such an hour
|
||||||
|
gives away stored energy AND forgoes being paid to take more in; if the house
|
||||||
|
load is covered it becomes export, which at a negative price is paid-for
|
||||||
|
disposal. The buy side is untouched - a negative hour is the best possible hour
|
||||||
|
to charge in, and this file's guard is deliberately asymmetric for that reason.
|
||||||
|
|
||||||
|
⚠️ THE GUARD IS ON THE HOUR BEING SOLD INTO, NOT ON THE SPREAD. A series whose
|
||||||
|
prices are -0.30 and -0.05 has a 0.25 EUR/kWh spread that clears any shipped
|
||||||
|
threshold, and its DEAREST hour is still negative. A guard written as
|
||||||
|
"spread > 0" or "the prices are not all negative" passes that case and exports.
|
||||||
|
|
||||||
|
|
||||||
|
WHAT `cycle_cost_threshold_eur` IS, AND WHAT THIS FILE REFUSES TO DO WITH IT
|
||||||
|
===========================================================================
|
||||||
|
§10.1 gates arbitrage on "expected round-trip benefit exceeds
|
||||||
|
`cycle_cost_threshold_eur`". It ships at 0.10 EUR/kWh. On realistic Belgian
|
||||||
|
day/night numbers the static spread is about 0.08 EUR/kWh, and FCST-03's
|
||||||
|
widening factors (2.5 real-time, 1.5 no-weather) are honest policy defaults
|
||||||
|
with nothing to derive them from.
|
||||||
|
|
||||||
|
⚠️ SO A STATIC-TARIFF SITE DOES NOT TRADE AT SHIPPED DEFAULTS, AND THAT IS
|
||||||
|
REPORTED RATHER THAN TUNED AWAY. The threshold is a parameter here, the
|
||||||
|
behaviour is correct at any value of it, and no number in this file was chosen
|
||||||
|
to make a trade happen. Whether 0.10 is the right shipped default - and whether
|
||||||
|
2.5/1.5 are - is one product decision and it belongs to the owner, not to this
|
||||||
|
card. test_arbitrage.py section 7 reports what the shipped default does over a
|
||||||
|
replayed year rather than adjusting anything so that it looks better.
|
||||||
|
|
||||||
|
|
||||||
|
THE ACCOUNTING BASIS, AND THE TERM THAT IS DELIBERATELY ABSENT
|
||||||
|
=============================================================
|
||||||
|
The card's technical notes ask for `benefit = value_out(d) - cost_in(c)/η`, and
|
||||||
|
then say of the round-trip efficiency: "η is NOT a §14 key - it needs one, or
|
||||||
|
must be derived from measured charge/discharge energy in the state store. DO
|
||||||
|
NOT HARDCODE IT." Both halves are honoured by not having the term at all:
|
||||||
|
|
||||||
|
* THE SELL SIDE IS PRICED AS DISPLACED IMPORT, NOT AS EXPORT. The two hours
|
||||||
|
compared are both all-in CONSUMPTION prices, which is the basis the notes
|
||||||
|
require ("credit displacement at all-in import prices, not at export price,
|
||||||
|
or the number flatters itself"). `export_price_eur_kwh` is therefore not
|
||||||
|
read here at all - §9.3's near-zero Belgian injection price is precisely
|
||||||
|
why the economically sound trade on this market is charge-cheap-to-
|
||||||
|
displace-expensive-import, and §17's guard below is the same fact stated as
|
||||||
|
a rule.
|
||||||
|
|
||||||
|
* AND THERE IS NO η, BECAUSE THERE IS NOTHING HONEST TO PUT IN IT. No config
|
||||||
|
key exists, the state store carries no measured charge/discharge energy
|
||||||
|
pair to derive one from, and a hardcoded 0.85 is exactly what the notes
|
||||||
|
forbid - it would also DOUBLE-COUNT, because `cycle_cost_threshold_eur` is
|
||||||
|
already defined as what a round trip costs per kWh and conversion loss is
|
||||||
|
part of that cost. When a measured η exists it belongs beside the threshold
|
||||||
|
as a second term, and this file's arithmetic is one subtraction, in one
|
||||||
|
place, ready for it.
|
||||||
|
|
||||||
|
|
||||||
|
HYSTERESIS (§10.2)
|
||||||
|
==================
|
||||||
|
The switching decision is "which hour is it", and an hour is 60 minutes wide,
|
||||||
|
so the action cannot chatter within one. The other two terms are monotone
|
||||||
|
inside an hour in the only direction that matters: a charge raises SoC towards
|
||||||
|
the ceiling and a discharge lowers it towards the floor, so each can stop once
|
||||||
|
and cannot restart in the same hour. No timer is needed and none is invented.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .arbiter import P_ARBITRAGE, Claim
|
||||||
|
from .price import Absence, price_option
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||||
|
|
||||||
|
SOURCE = "arbitrage"
|
||||||
|
|
||||||
|
# §10.2 reason codes. Written out because the action log is a user-facing
|
||||||
|
# artefact and a code assembled from an f-string cannot be grepped for.
|
||||||
|
CHARGE = "ARBITRAGE_CHARGE"
|
||||||
|
DISCHARGE = "ARBITRAGE_DISCHARGE"
|
||||||
|
|
||||||
|
# Why nothing was done. These are the reasons this MODULE owns; the five
|
||||||
|
# refusals that belong to the verdict itself are carried through verbatim from
|
||||||
|
# static_tariff.py rather than restated, so a refusal's wording has exactly one
|
||||||
|
# author.
|
||||||
|
IDLE_NO_PLAN = "no tariff plan has been resolved yet"
|
||||||
|
IDLE_NOT_COVERED = (
|
||||||
|
"NOT COVERED: meter_source is off, so the capacity-tariff peak guard never "
|
||||||
|
"runs and nothing measures the quarter this charge would land in. §9.1 puts "
|
||||||
|
"peak shaving above arbitrage, and an unmeasured peak cannot be shaved - "
|
||||||
|
"so the honest answer is that arbitrage is not covered on this install, not "
|
||||||
|
"that the guard is clear")
|
||||||
|
IDLE_NO_SOC = (
|
||||||
|
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||||
|
"cannot be told to empty or to fill")
|
||||||
|
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||||
|
NEGATIVE_PRICE = (
|
||||||
|
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||||
|
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||||
|
"negative price")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArbitrageDecision:
|
||||||
|
"""What the strategy concluded this cycle, with what it concluded it from.
|
||||||
|
|
||||||
|
⚠️ NO FIELD HAS A DEFAULT, and that is TEST-14's rule rather than a style
|
||||||
|
choice: an outcome type's required field quietly gaining a default is a
|
||||||
|
mutation that survived review in three files in this repository. The
|
||||||
|
signature is scanned in test_price_forecast.py alongside `Gate`'s and
|
||||||
|
`Arbitrage`'s constructors, so a default added here later goes red without
|
||||||
|
anybody remembering to write a case for it.
|
||||||
|
|
||||||
|
`action` is None whenever nothing is to be done, and `power_w` is then None
|
||||||
|
too - not 0.0. Zero watts is a COMMAND (it parks the battery); "no claim"
|
||||||
|
is the absence of one, and the arbiter must be free to let the loop win.
|
||||||
|
"""
|
||||||
|
|
||||||
|
action: str | None # CHARGE | DISCHARGE | None
|
||||||
|
power_w: float | None # signed: negative charges, positive discharges
|
||||||
|
reason: str
|
||||||
|
spread_eur_kwh: float | None
|
||||||
|
required_eur_kwh: float | None
|
||||||
|
price_now_eur_kwh: float | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trading(self) -> bool:
|
||||||
|
return self.action is not None
|
||||||
|
|
||||||
|
def explain(self) -> str:
|
||||||
|
if not self.trading:
|
||||||
|
return f"arbitrage idle - {self.reason}"
|
||||||
|
return self.reason
|
||||||
|
|
||||||
|
|
||||||
|
def _idle(reason, *, spread=None, required=None, price=None) -> ArbitrageDecision:
|
||||||
|
if not reason or not isinstance(reason, str):
|
||||||
|
raise ValueError(
|
||||||
|
"an idle arbitrage decision needs a reason a human can act on - "
|
||||||
|
"§10.2 requires every plan change to carry one, and 'arbitrage did "
|
||||||
|
"nothing' with no reason is indistinguishable from a bug.")
|
||||||
|
return ArbitrageDecision(action=None, power_w=None, reason=reason,
|
||||||
|
spread_eur_kwh=spread, required_eur_kwh=required,
|
||||||
|
price_now_eur_kwh=price)
|
||||||
|
|
||||||
|
|
||||||
|
def enabled(opts: dict) -> bool:
|
||||||
|
"""Is the strategy switched on at all? Off in the shipped configuration.
|
||||||
|
|
||||||
|
⚠️ OFF BY DEFAULT ON PURPOSE. Every other card in this repository ships its
|
||||||
|
machinery inert and lets an operator arm it; this one MOVES ENERGY against
|
||||||
|
the meter on a real battery, so it is the last one that should be an
|
||||||
|
exception to that.
|
||||||
|
"""
|
||||||
|
v = price_option(opts, "arbitrage_enabled")
|
||||||
|
return v is True or str(v).strip().lower() in ("true", "yes", "on", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def threshold_eur_kwh(opts: dict) -> float:
|
||||||
|
"""§10.1's `cycle_cost_threshold_eur`, from the one place option defaults live."""
|
||||||
|
v = float(price_option(opts, "cycle_cost_threshold_eur"))
|
||||||
|
if not math.isfinite(v) or v < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
|
||||||
|
f"in EUR/kWh, so it is non-negative and finite - a negative "
|
||||||
|
f"threshold is one every spread clears, which is arbitrage with the "
|
||||||
|
f"brake removed.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def soc_bounds(opts: dict) -> tuple:
|
||||||
|
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
|
||||||
|
return (float(price_option(opts, "arbitrage_soc_floor")),
|
||||||
|
float(price_option(opts, "arbitrage_soc_ceiling")))
|
||||||
|
|
||||||
|
|
||||||
|
def guard_state(peak_decision):
|
||||||
|
"""The peak guard's answer as the three-state `decide()` takes: T / F / None.
|
||||||
|
|
||||||
|
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||||
|
`peakguard.shaping_suspended()`, `watchdog.loop_is_following()` and
|
||||||
|
`freeze.looks_frozen()` are: the check that grades it has to assert on THE
|
||||||
|
SHIPPED EXPRESSION. A 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 - this repository has three separate
|
||||||
|
records of exactly that, one of which left every suite green while a
|
||||||
|
default install parked at 0 W in the quarter that sets the month's bill.
|
||||||
|
|
||||||
|
⚠️ `None` IS NOT `False` AND COLLAPSING THE TWO IS THE WHOLE HAZARD. On
|
||||||
|
`meter_source: off` main.py never runs peakguard.decide() - there is no
|
||||||
|
telegram stream, so there is no open quarter to project - and the decision
|
||||||
|
it hands here is None. That is "the guard never ran", not "the guard is
|
||||||
|
clear", and answering False would arm a strategy whose charging creates the
|
||||||
|
billed quantity (§9.1) while nothing measures the quarter it lands in.
|
||||||
|
"""
|
||||||
|
if peak_decision is None:
|
||||||
|
return None
|
||||||
|
return bool(peak_decision.engaged)
|
||||||
|
|
||||||
|
|
||||||
|
def _point_at(plan, when: datetime):
|
||||||
|
"""The price point covering `when`, or None when the series does not cover it.
|
||||||
|
|
||||||
|
⚠️ None IS NOT A PRICE AND IS NOT ZERO. `TariffPlan` raises `Absence` here
|
||||||
|
rather than answering 0.0, for price.py's reason - "no data" becoming "free"
|
||||||
|
is the defect the whole outcome-type family exists to remove - and this
|
||||||
|
turns that into "do nothing", never into "it is cheap".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return plan.read.match(series=lambda r: r.at(when),
|
||||||
|
no_data=lambda _r: None)
|
||||||
|
except Absence:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||||
|
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
|
||||||
|
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
|
||||||
|
"""Should the battery trade this cycle, and how hard.
|
||||||
|
|
||||||
|
⚠️ `peak_guard_active` IS A THREE-STATE ARGUMENT AND ALL THREE ARE REAL.
|
||||||
|
True (the guard is holding), False (it ran and is clear), and None (it never
|
||||||
|
ran, because nothing measures this install's quarter). See the module note:
|
||||||
|
None refuses, because the alternative is a strategy that creates the billed
|
||||||
|
quantity while the thing that would have limited it is switched off.
|
||||||
|
|
||||||
|
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
|
||||||
|
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
|
||||||
|
floor this module asserted rather than one the operator configured, and the
|
||||||
|
first time it is wrong the battery is at 0 %.
|
||||||
|
"""
|
||||||
|
if peak_guard_active is None:
|
||||||
|
return _idle(IDLE_NOT_COVERED)
|
||||||
|
if plan is None:
|
||||||
|
return _idle(IDLE_NO_PLAN)
|
||||||
|
|
||||||
|
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
|
||||||
|
min_spread_eur_kwh=min_spread_eur_kwh,
|
||||||
|
uncertainty=uncertainty)
|
||||||
|
return verdict.match(
|
||||||
|
refused=lambda v: _idle(v.reason),
|
||||||
|
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||||
|
soc_floor_pct, soc_ceiling_pct))
|
||||||
|
|
||||||
|
|
||||||
|
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||||
|
"""The permitted case: which hour is this, and may the pack do it."""
|
||||||
|
here = _point_at(plan, now)
|
||||||
|
if here is None:
|
||||||
|
return _idle(
|
||||||
|
f"the series carries a tradeable spread but no price for this "
|
||||||
|
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
|
||||||
|
f"than a reason to reach for a neighbouring one",
|
||||||
|
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
|
||||||
|
|
||||||
|
price = here.eur_per_kwh
|
||||||
|
buying = here.start == v.cheapest.start
|
||||||
|
selling = here.start == v.dearest.start
|
||||||
|
if not (buying or selling):
|
||||||
|
return _idle(IDLE_OFF_PEAK_HOUR, spread=v.spread_eur_kwh,
|
||||||
|
required=v.required_spread_eur_kwh, price=price)
|
||||||
|
|
||||||
|
# ⚠️ BELT AND BRACES ON §8.3, AND IT IS NOT DEAD CODE. `_firm_or_die` makes
|
||||||
|
# the two points on the verdict firm at construction, but the point matched
|
||||||
|
# HERE is whatever `at()` returned for this instant, and the identity of the
|
||||||
|
# two rests on the series being the one the verdict was built from. An hour
|
||||||
|
# that is provisional is one nobody published a price for, and no
|
||||||
|
# irreversible action may rest on it.
|
||||||
|
if here.provisional:
|
||||||
|
return _idle(
|
||||||
|
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
|
||||||
|
f"CARRIED FORWARD, not published, and no irreversible action may be "
|
||||||
|
f"taken on a provisional price",
|
||||||
|
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||||
|
price=price)
|
||||||
|
|
||||||
|
if soc is None:
|
||||||
|
return _idle(IDLE_NO_SOC, spread=v.spread_eur_kwh,
|
||||||
|
required=v.required_spread_eur_kwh, price=price)
|
||||||
|
|
||||||
|
common = dict(spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||||
|
price=price)
|
||||||
|
|
||||||
|
if selling:
|
||||||
|
# §17. THE NAMED REGRESSION CASE. Checked BEFORE the SoC question, so a
|
||||||
|
# negative price reports the reason that actually forbids the trade
|
||||||
|
# rather than one that happens to fire first on some other site.
|
||||||
|
if price < 0:
|
||||||
|
return _idle(f"{NEGATIVE_PRICE} - this hour is "
|
||||||
|
f"{price:+.4f} EUR/kWh", **common)
|
||||||
|
if soc <= floor_pct:
|
||||||
|
return _idle(
|
||||||
|
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||||
|
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||||
|
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||||
|
return ArbitrageDecision(
|
||||||
|
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||||
|
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||||
|
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
|
||||||
|
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
|
||||||
|
f"this cycle had to cover; discharging "
|
||||||
|
f"{abs(float(discharge_w)):.0f} W from {soc:g} %"),
|
||||||
|
spread_eur_kwh=v.spread_eur_kwh,
|
||||||
|
required_eur_kwh=v.required_spread_eur_kwh,
|
||||||
|
price_now_eur_kwh=price)
|
||||||
|
|
||||||
|
if soc >= ceiling_pct:
|
||||||
|
return _idle(
|
||||||
|
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||||
|
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||||
|
f"nowhere to put the cheap energy", **common)
|
||||||
|
return ArbitrageDecision(
|
||||||
|
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||||
|
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||||
|
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||||
|
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||||
|
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
|
||||||
|
f"{soc:g} %"),
|
||||||
|
spread_eur_kwh=v.spread_eur_kwh,
|
||||||
|
required_eur_kwh=v.required_spread_eur_kwh,
|
||||||
|
price_now_eur_kwh=price)
|
||||||
|
|
||||||
|
|
||||||
|
def claim(decision: ArbitrageDecision) -> Claim | None:
|
||||||
|
"""The decision as one arbiter claim, or None when nothing is to be traded.
|
||||||
|
|
||||||
|
A SET at P_ARBITRAGE, and the priority is the whole §10.1 ordering:
|
||||||
|
|
||||||
|
P_PEAK 50 > this, so peakguard's `lo` floor CLAMPS an arbitrage
|
||||||
|
charge (arbiter clause 2 applies every limit at or
|
||||||
|
above the winner's priority). AC3, mechanically.
|
||||||
|
P_MAINT_SHAPE 40 > this, so a maintenance charge window's `hi` shaping is
|
||||||
|
not overridden by a price opportunity. §12.4's "money
|
||||||
|
outranks the schedule" is about the PEAK guard, which
|
||||||
|
is measured and billed; a speculative spread is not
|
||||||
|
that, and letting it out-rank the pack's balancing
|
||||||
|
cycle would be this card quietly rewriting §12.
|
||||||
|
P_LOOP 10 < this, because trading against the meter is the point:
|
||||||
|
the grid-following loop would otherwise cancel every
|
||||||
|
charge the moment it created import.
|
||||||
|
|
||||||
|
⚠️ NONE MEANS NO CLAIM, NOT A 0 W CLAIM. Filing `Claim.set(..., 0.0)` here
|
||||||
|
would out-rank the loop with a park order every cycle the strategy had
|
||||||
|
nothing to say, i.e. it would silently disable self-consumption on any site
|
||||||
|
that armed arbitrage.
|
||||||
|
"""
|
||||||
|
if not decision.trading or decision.power_w is None:
|
||||||
|
return None
|
||||||
|
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
|
||||||
|
decision.action)
|
||||||
@@ -28,10 +28,11 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from . import arbitrage
|
||||||
from .arbiter import (
|
from .arbiter import (
|
||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||||
)
|
)
|
||||||
@@ -48,7 +49,8 @@ from .maintenance import IDLE, MaintConfig, Maintenance
|
|||||||
from .mqtt import MqttPublisher
|
from .mqtt import MqttPublisher
|
||||||
from . import peakguard
|
from . import peakguard
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
from .static_tariff import static_status
|
from .price import Uncertainty, price_option
|
||||||
|
from .static_tariff import resolve_tariff, static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
from .tariff import PeakStore, PeakTracker
|
||||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||||
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
||||||
@@ -57,6 +59,34 @@ from . import web
|
|||||||
OPTIONS_PATH = "/data/options.json"
|
OPTIONS_PATH = "/data/options.json"
|
||||||
_LOG = logging.getLogger("goodwe")
|
_LOG = logging.getLogger("goodwe")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
|
||||||
|
#
|
||||||
|
# `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick
|
||||||
|
# is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat
|
||||||
|
# stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able
|
||||||
|
# to push this add-on into the failsafe. Every module in the price stack says
|
||||||
|
# so in its own words (static_tariff.static_plan, resolve_tariff, main's
|
||||||
|
# read_inputs) and this is the task those notes were waiting for.
|
||||||
|
#
|
||||||
|
# The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an
|
||||||
|
# attribute this task assigns, and nothing else.
|
||||||
|
#
|
||||||
|
# ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed
|
||||||
|
# publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that
|
||||||
|
# moment; 30 minutes is short enough that the switch is picked up inside the
|
||||||
|
# hour it happens and long enough to be a polite 48 fetches a day. Nothing in
|
||||||
|
# the control law depends on it.
|
||||||
|
TARIFF_REFRESH_S = 1800.0
|
||||||
|
# ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still
|
||||||
|
# stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which
|
||||||
|
# is provisional and refuses arbitrage on its own. This bound covers the other
|
||||||
|
# case: the refresh task itself dying, leaving a plan that was true hours ago
|
||||||
|
# standing as if it were current. Three missed refreshes is the threshold, for
|
||||||
|
# `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is.
|
||||||
|
# A failed read is not an observation, and neither is a stale one.
|
||||||
|
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||||
|
|
||||||
|
|
||||||
def load_options() -> dict:
|
def load_options() -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -199,14 +229,25 @@ class Controller:
|
|||||||
# the action log and the notification fire on transitions rather than
|
# the action log and the notification fire on transitions rather than
|
||||||
# once per cycle for a quarter of an hour.
|
# once per cycle for a quarter of an hour.
|
||||||
#
|
#
|
||||||
# ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90
|
# ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp
|
||||||
# lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already
|
# watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`,
|
||||||
# shipped and already validated, and every other term is read off
|
# already shipped and already validated, and every other term of the
|
||||||
# something the arbiter or the averager already owns. CFG-03 still
|
# projection is read off something the arbiter or the averager already
|
||||||
# tracks three places, not four.
|
# owns.
|
||||||
|
#
|
||||||
|
# ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far
|
||||||
|
# down the guard may spend the pack for money is a number only the site
|
||||||
|
# knows, and refusing one set below the pack's own cut-out means knowing
|
||||||
|
# that cut-out too. So this card adds exactly two keys and reads their
|
||||||
|
# defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror
|
||||||
|
# pattern - rather than typing literals here. THAT is what keeps CFG-03
|
||||||
|
# at three places: the divergence validate.py's rule 6 documents
|
||||||
|
# (`maintenance_charge_w` 2000 there, 2500 here) is exactly what a
|
||||||
|
# literal in this file would repeat.
|
||||||
self.peak_guard = None
|
self.peak_guard = None
|
||||||
self._peak_engaged = False
|
self._peak_engaged = False
|
||||||
self._peak_saturated = False
|
self._peak_saturated = False
|
||||||
|
self._peak_floor_limited = False
|
||||||
self._maint_shape_suspended = False
|
self._maint_shape_suspended = False
|
||||||
# Set by amain() once the transport is built, so the status page can show
|
# 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
|
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||||
@@ -232,6 +273,16 @@ class Controller:
|
|||||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||||
# no" is an evidenced answer rather than a bare no.
|
# no" is an evidenced answer rather than a bare no.
|
||||||
self.tariff_words = "no static profile"
|
self.tariff_words = "no static profile"
|
||||||
|
|
||||||
|
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
|
||||||
|
# cycle() reads these two attributes and never awaits a fetch - see
|
||||||
|
# TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is
|
||||||
|
# not "no prices" and certainly not "free": arbitrage.decide() reports
|
||||||
|
# it as its own reason and does nothing.
|
||||||
|
self.tariff_plan = None
|
||||||
|
self.tariff_at = None
|
||||||
|
self.arb = None
|
||||||
|
self._arb_action = None
|
||||||
self.last_write = None
|
self.last_write = None
|
||||||
self.last_write_at = None
|
self.last_write_at = None
|
||||||
self.last_write_ok = None
|
self.last_write_ok = None
|
||||||
@@ -494,6 +545,17 @@ class Controller:
|
|||||||
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||||
reading=self.peak.peak(now),
|
reading=self.peak.peak(now),
|
||||||
achievable_w=min(max_w, abs(float(dev_top))),
|
achievable_w=min(max_w, abs(float(dev_top))),
|
||||||
|
# ⚠️ SAFETY-24. `self.soc` IS THE MEASURED READING AND MAY BE None,
|
||||||
|
# and it is handed over as None rather than defaulted here. An early
|
||||||
|
# draft of the stage-5 loop read SoC as `| float(100)` and thereby
|
||||||
|
# invented a full battery (NOTES.md 4.4b); a full battery is
|
||||||
|
# precisely the value that disarms this floor. decide() treats None
|
||||||
|
# as floor-limited, which is the direction that costs a shave rather
|
||||||
|
# than the pack.
|
||||||
|
soc=self.soc,
|
||||||
|
soc_floor=float(self.o.get(
|
||||||
|
"peak_soc_floor",
|
||||||
|
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
|
||||||
max_hold_s=self.p1.averager.max_hold_s,
|
max_hold_s=self.p1.averager.max_hold_s,
|
||||||
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
|
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
|
||||||
standing, max_w, self.freeze_wd.cfg),
|
standing, max_w, self.freeze_wd.cfg),
|
||||||
@@ -514,6 +576,64 @@ class Controller:
|
|||||||
if self.peak_guard is not None:
|
if self.peak_guard is not None:
|
||||||
self.log_event(self.peak_guard.explain())
|
self.log_event(self.peak_guard.explain())
|
||||||
|
|
||||||
|
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
|
||||||
|
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
|
||||||
|
# argument. What is decided HERE and nowhere else is what the strategy
|
||||||
|
# is allowed to see:
|
||||||
|
#
|
||||||
|
# ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION.
|
||||||
|
# `engaged_now` is this cycle's peak verdict, computed ~20 lines up.
|
||||||
|
# Evaluated before it, this would hand the strategy the PREVIOUS cycle's
|
||||||
|
# answer - one cycle of lag on the interlock that implements §9.1's
|
||||||
|
# "peak shaving outranks arbitrage", inside a 900 s quarter.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not
|
||||||
|
# CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on
|
||||||
|
# it, because charging at max_w creates the billed quantity while
|
||||||
|
# nothing is measuring the quarter it lands in. `False` here would be
|
||||||
|
# this line asserting a measurement that was never taken - the defect
|
||||||
|
# `PeakReading` and `quarter_ceiling_w()` both exist to remove.
|
||||||
|
#
|
||||||
|
# ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT
|
||||||
|
# HERE, and that is not tidiness. It is the same lesson
|
||||||
|
# `shaping_suspended()` records: inline, this expression was reachable
|
||||||
|
# by no check, and mutating it back to a bare `engaged_now` left every
|
||||||
|
# suite in this repository green while a `meter_source: off` install
|
||||||
|
# traded blind. Read guard_state() before touching this line.
|
||||||
|
#
|
||||||
|
# ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read
|
||||||
|
# and an age comparison; the HTTPS call lives in run_tariff_refresh().
|
||||||
|
# See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a
|
||||||
|
# supplier able to push this add-on towards layer 1's 30 s veto.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's
|
||||||
|
# no-weather row widens the threshold, and this add-on carries no
|
||||||
|
# weather forecast at all - §8.1's PV forecast is not built. Passing
|
||||||
|
# `frozenset()` would be main.py asserting a forecast is healthy when
|
||||||
|
# there is not one, which is exactly what static_tariff's `uncertainty`
|
||||||
|
# argument has no default in order to prevent.
|
||||||
|
self.arb = None
|
||||||
|
if arbitrage.enabled(self.o):
|
||||||
|
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||||
|
self.arb = arbitrage.decide(
|
||||||
|
self.fresh_tariff_plan(now), now,
|
||||||
|
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||||
|
soc=self.soc,
|
||||||
|
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||||
|
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||||
|
charge_w=max_w, discharge_w=max_w,
|
||||||
|
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||||
|
arb_claim = arbitrage.claim(self.arb)
|
||||||
|
if arb_claim is not None:
|
||||||
|
claims.append(arb_claim)
|
||||||
|
# Edge-logged, for the peak guard's reason: the action log holds 40
|
||||||
|
# lines and a trade lasts a whole hour, so a line per cycle would
|
||||||
|
# flush the context that explains it. The transition is what an
|
||||||
|
# operator needs - "it started buying at 03:00", not 240 copies.
|
||||||
|
if self.arb.action != self._arb_action:
|
||||||
|
self._arb_action = self.arb.action
|
||||||
|
self.log_event(self.arb.explain())
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------
|
# --- maintenance -----------------------------------------------------
|
||||||
if result.owns_setpoint:
|
if result.owns_setpoint:
|
||||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||||
@@ -682,6 +802,16 @@ class Controller:
|
|||||||
# method assigned above and nothing since has touched.
|
# method assigned above and nothing since has touched.
|
||||||
await self.check_peak_saturated()
|
await self.check_peak_saturated()
|
||||||
|
|
||||||
|
# --- SAFETY-24's alarm, and it is the OTHER way the money gets spent --
|
||||||
|
# Same boundary argument as check_peak_saturated(): it grades
|
||||||
|
# `self.peak_guard`, which cycle() assigned above and nothing since has
|
||||||
|
# touched. Kept as its own method rather than folded into that one,
|
||||||
|
# because the two states are mutually exclusive and want OPPOSITE
|
||||||
|
# operator actions - saturation says "shed load, the inverter is already
|
||||||
|
# flat out"; this says "the pack is at its floor and this quarter is not
|
||||||
|
# being shaved at all".
|
||||||
|
await self.check_peak_floor_limited()
|
||||||
|
|
||||||
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
||||||
# Banking a quarter is bookkeeping about a moment that has already
|
# Banking a quarter is bookkeeping about a moment that has already
|
||||||
# happened; a slow SD card must never sit between the meter and the
|
# happened; a slow SD card must never sit between the meter and the
|
||||||
@@ -826,6 +956,70 @@ class Controller:
|
|||||||
f"thing that changes the bill."),
|
f"thing that changes the bill."),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async def check_peak_floor_limited(self) -> None:
|
||||||
|
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
||||||
|
|
||||||
|
⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
|
||||||
|
The guard exists for one euro figure - the capaciteitstarief bills the
|
||||||
|
month's single worst quarter - so a cycle in which it CHOSE not to shave
|
||||||
|
is a cycle in which money is being spent on purpose. Under-shaving
|
||||||
|
quietly would leave the operator with a bill and a status page full of
|
||||||
|
green.
|
||||||
|
|
||||||
|
The distinction from check_peak_saturated() is the operator's action,
|
||||||
|
which is why these are two notifications and not one:
|
||||||
|
|
||||||
|
saturated the guard asked for everything the inverter has and it
|
||||||
|
was not enough. SHED LOAD - the controller has already
|
||||||
|
done all it can, and it is still discharging.
|
||||||
|
floor-limited the guard asked for NOTHING, because the pack is at or
|
||||||
|
under the EMS floor, or its SoC could not be read.
|
||||||
|
Nothing is being discharged for this quarter at all.
|
||||||
|
|
||||||
|
They cannot both be true: peakguard.decide()'s floor-limited branch
|
||||||
|
returns before the `achievable_w` clamp that sets `saturated`.
|
||||||
|
|
||||||
|
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
|
||||||
|
for FreezeWatchdog's reason: one notification per episode, not one per
|
||||||
|
cycle for a quarter of an hour.
|
||||||
|
"""
|
||||||
|
g = self.peak_guard
|
||||||
|
limited = bool(g is not None and g.floor_limited)
|
||||||
|
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||||
|
if not limited or was:
|
||||||
|
return
|
||||||
|
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
|
||||||
|
# a second copy is the kind of stutter that makes a 40-line action log
|
||||||
|
# read like boilerplate. The saturated path needs its prefix because its
|
||||||
|
# reason carries the word in the middle of a sentence, not at the front.
|
||||||
|
self.log_event(g.reason)
|
||||||
|
_LOG.error(
|
||||||
|
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
|
||||||
|
"still blocked, but it is not discharging to hold this quarter, so "
|
||||||
|
"the quarter will set or raise the month's billed capacity peak. "
|
||||||
|
"Shed load, or lower peak_soc_floor if this pack can afford it.",
|
||||||
|
g.reason)
|
||||||
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
|
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||||
|
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||||
|
# and an operator who has to dismiss three identical-looking cards
|
||||||
|
# learns to dismiss the one that mattered.
|
||||||
|
await self.hass.call("persistent_notification", "create", {
|
||||||
|
"notification_id": "goodwe_peak_soc_floor",
|
||||||
|
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||||
|
"message": (
|
||||||
|
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
|
||||||
|
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
|
||||||
|
f"month's billed capaciteitstarief peak, because the peak guard "
|
||||||
|
f"is not allowed to discharge any further: {g.soc_floor_reason}."
|
||||||
|
f" This is not a controller fault - it is the EMS-side energy "
|
||||||
|
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
|
||||||
|
f"deliberately above the pack's own BMS cut-out. Charging stays "
|
||||||
|
f"blocked. Shedding load is the only thing that changes the bill "
|
||||||
|
f"now; if this pack can afford a deeper discharge, lower "
|
||||||
|
f"peak_soc_floor."),
|
||||||
|
})
|
||||||
|
|
||||||
async def check_meter_frozen(self) -> None:
|
async def check_meter_frozen(self) -> None:
|
||||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||||
|
|
||||||
@@ -866,6 +1060,57 @@ class Controller:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# -- tasks ---------------------------------------------------------------
|
# -- tasks ---------------------------------------------------------------
|
||||||
|
# -- OPT-04: the background tariff refresh -------------------------------
|
||||||
|
def fresh_tariff_plan(self, now):
|
||||||
|
"""The stored plan if it is still current, else None. NEVER a stale one.
|
||||||
|
|
||||||
|
⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
||||||
|
`loop_is_following()`'s reason: a check has to be able to assert on the
|
||||||
|
shipped expression rather than on a transcription that drifts. Both
|
||||||
|
terms are required - a plan with no timestamp is one nothing stored, and
|
||||||
|
a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task
|
||||||
|
that has stopped answering.
|
||||||
|
"""
|
||||||
|
if self.tariff_plan is None or self.tariff_at is None:
|
||||||
|
return None
|
||||||
|
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
||||||
|
return None
|
||||||
|
return self.tariff_plan
|
||||||
|
|
||||||
|
async def run_tariff_refresh(self) -> None:
|
||||||
|
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
||||||
|
|
||||||
|
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason:
|
||||||
|
a supplier's outage, a certificate expiry, a malformed feed - all of it
|
||||||
|
is pricing, and the battery does not care. What it must never do is
|
||||||
|
raise out of here and take the task down, because then the plan freezes
|
||||||
|
at its last value and looks current forever. That is what
|
||||||
|
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
|
||||||
|
half that keeps the task alive so the bound rarely matters.
|
||||||
|
|
||||||
|
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
|
||||||
|
a §11.4 answer for a dead feed - degrade to the static profile, mark it
|
||||||
|
provisional, arbitrage refuses - and that answer is better than the
|
||||||
|
absence of one. Zeroing the plan here would replace a coarse tariff with
|
||||||
|
no tariff, which is the opposite of what §11.4 asks for.
|
||||||
|
"""
|
||||||
|
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||||
|
while not self.stopping:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
plan = await resolve_tariff(
|
||||||
|
self.o, now, now + timedelta(hours=horizon), now=now)
|
||||||
|
self.tariff_plan, self.tariff_at = plan, now
|
||||||
|
_LOG.info("tariff refreshed: %s", plan.words)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOG.warning(
|
||||||
|
"tariff refresh failed (%s); keeping the plan resolved at "
|
||||||
|
"%s. It retires on its own after %.0f s and arbitrage "
|
||||||
|
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||||
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(self.stop_event.wait(),
|
||||||
|
timeout=TARIFF_REFRESH_S)
|
||||||
|
|
||||||
async def run_control(self) -> None:
|
async def run_control(self) -> None:
|
||||||
entity = self.o.get("setpoint_entity", "")
|
entity = self.o.get("setpoint_entity", "")
|
||||||
if entity:
|
if entity:
|
||||||
@@ -1116,6 +1361,13 @@ class Controller:
|
|||||||
elif _pg.saturated:
|
elif _pg.saturated:
|
||||||
out.append({"ok": False, "warn": False,
|
out.append({"ok": False, "warn": False,
|
||||||
"text": "PEAK GUARD SATURATED - " + _pg.reason})
|
"text": "PEAK GUARD SATURATED - " + _pg.reason})
|
||||||
|
elif _pg.floor_limited:
|
||||||
|
# SAFETY-24. NOT ok, like saturation: in both states the money
|
||||||
|
# is going to be spent and a human is the only actuator left. A
|
||||||
|
# separate row from saturation because the two ask for opposite
|
||||||
|
# things - see check_peak_floor_limited().
|
||||||
|
out.append({"ok": False, "warn": False,
|
||||||
|
"text": _pg.explain()})
|
||||||
elif _pg.blind:
|
elif _pg.blind:
|
||||||
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
||||||
else:
|
else:
|
||||||
@@ -1243,6 +1495,11 @@ class Controller:
|
|||||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("Tariff", self.tariff_words),
|
||||||
|
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||||
|
# every refusal it can make already carries a reason a human can
|
||||||
|
# act on, and "Arbitrage: no" would throw all of them away.
|
||||||
|
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
|
||||||
|
else self.arb.explain()),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||||
@@ -1370,6 +1627,19 @@ async def amain() -> None:
|
|||||||
|
|
||||||
tasks = [asyncio.create_task(controller.run_control())]
|
tasks = [asyncio.create_task(controller.run_control())]
|
||||||
|
|
||||||
|
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
|
||||||
|
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
|
||||||
|
# HTTPS connection to a supplier, and the control tick is what feeds the
|
||||||
|
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||||
|
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||||
|
#
|
||||||
|
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||||
|
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||||
|
# the tick, which is pure - so on a default install this task would be
|
||||||
|
# network traffic in aid of an attribute nobody reads.
|
||||||
|
if arbitrage.enabled(opts):
|
||||||
|
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||||
|
|
||||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||||
# off the control loop: telegrams arrive every ~5 s and the loop would
|
# off the control loop: telegrams arrive every ~5 s and the loop would
|
||||||
# decimate them, so the 15-minute average - the capacity-tariff billing
|
# decimate them, so the 15-minute average - the capacity-tariff billing
|
||||||
|
|||||||
@@ -246,19 +246,100 @@ validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
|||||||
mismatch trap 14's own warning box describes.
|
mismatch trap 14's own warning box describes.
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
|
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
|
||||||
|
|
||||||
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
|
SAFETY-07 left this file saying, in its own words, that the graded branch can
|
||||||
below it consults the state of charge - `soc_floor` belongs to the maintenance
|
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
|
||||||
cycle, and freeze.py already records that for the grid-following loop "the BMS
|
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
|
||||||
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
|
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
|
||||||
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
|
quarter is over the ceiling. That is the behaviour working as designed, and it
|
||||||
keep asking as long as the meter says the quarter is over the ceiling.
|
is unbounded from below by anything in the EMS.
|
||||||
|
|
||||||
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
|
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
|
||||||
reason this must not go to a client install before SAFETY-24 lands: on the
|
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
|
||||||
reference site the BMS is a known quantity and an operator is present, and on a
|
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
|
||||||
client site neither is true.
|
leave the battery repeatedly parked at that floor with no EMS-side record of
|
||||||
|
why, and the first symptom - a pack that is never available when it is wanted -
|
||||||
|
is indistinguishable from the SoC drift MAINT-01 exists to correct.
|
||||||
|
|
||||||
|
So `decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is
|
||||||
|
the one place the question is answered. Four things about the shape of it:
|
||||||
|
|
||||||
|
1. IT IS NOT A TRIP, AND IT MUST NOT BECOME ONE. SAFETY-07's claim is a LIMIT
|
||||||
|
and every stop in 11.2's table still outranks it. This floor is about
|
||||||
|
ENERGY - how far down the guard may spend the pack for money - not about
|
||||||
|
last-resort safety. The action when it binds is therefore to stop ASKING
|
||||||
|
for more discharge, not to command anything.
|
||||||
|
|
||||||
|
2. THE ACTION IS `floor_w = 0.0`, WHICH IS THE BLIND BRANCH'S ACTION FOR THE
|
||||||
|
BLIND BRANCH'S REASON. Zero is one-directional: it forbids CHARGING, which
|
||||||
|
can only ever lower delivered offtake, and it forbids nothing else. It
|
||||||
|
cannot pull the loop off a discharge it is already doing, because a `lo`
|
||||||
|
limit structurally cannot cap discharge - the property this module's whole
|
||||||
|
shape rests on, and one a NEGATIVE floor would destroy. Nothing here can
|
||||||
|
produce a negative floor: the only two values `floor_w` ever takes are 0.0
|
||||||
|
and `min(wanted, achievable)` with `wanted > 0` already established.
|
||||||
|
|
||||||
|
⚠️ AND IT DOES NOT BOUND THE LOOP. Self-consumption may still drain the
|
||||||
|
pack past this floor, exactly as SAFETY-18 recorded. That is not fixed
|
||||||
|
here and must not be claimed: this card bounds THE PEAK GUARD's OWN forced
|
||||||
|
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
|
||||||
|
is a different lever with a different right value.
|
||||||
|
|
||||||
|
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
|
||||||
|
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
|
||||||
|
actually being asked for. Asked earlier it would grade a quantity that
|
||||||
|
governs nothing, and would report "floor-limited" on a quiet afternoon
|
||||||
|
where the guard was never going to discharge anyway.
|
||||||
|
|
||||||
|
4. AN UNREADABLE SoC IS NOT A FULL BATTERY. `soc=None` is floor-limited, not
|
||||||
|
permitted. This is `PeakReading`'s lesson in the one place it bites
|
||||||
|
hardest: a full battery is exactly the value that would disarm this floor,
|
||||||
|
so a failed read defaulting to one would silently restore the unbounded
|
||||||
|
discharge this card exists to remove. Both new arguments are REQUIRED
|
||||||
|
keyword-only and neither has a default, which is TEST-14 read as a rule
|
||||||
|
rather than as a story: a required field that gains a default is how three
|
||||||
|
separate files in this repo shipped a quiet fallback.
|
||||||
|
|
||||||
|
AND FAILING TO SHAVE MUST NOT BE SILENT. The whole point of the guard is the
|
||||||
|
euro figure, so a floor-limited cycle carries `floor_limited=True` and main.py
|
||||||
|
raises ONE notification on the edge, saying the quarter will be exceeded - the
|
||||||
|
same shape, and the same argument, as `saturated`. The two are mutually
|
||||||
|
exclusive by construction: a floor-limited decision asks for nothing, so it
|
||||||
|
cannot have run out of inverter.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
CFG-03: TWO NEW OPTIONS, MIRRORED ONCE, AND WHY BOTH ARE NEEDED
|
||||||
|
|
||||||
|
SAFETY-07 added none and said so. This card cannot: an EMS-side floor is a
|
||||||
|
number only the site knows, and refusing one set below the pack's own cut-out
|
||||||
|
means knowing that cut-out too.
|
||||||
|
|
||||||
|
* `peak_soc_floor` - the EMS floor. ⚠️ NOT `maintenance_soc_floor`. That is
|
||||||
|
the maintenance cycle's DRAIN TARGET: the depth a monthly recalibration
|
||||||
|
deliberately goes to, once, under supervision. This is the depth a
|
||||||
|
money-saving discharge may never reach, on any day of the month. Sharing
|
||||||
|
one key would couple two features whose right values differ and would widen
|
||||||
|
CFG-03 rather than narrow it.
|
||||||
|
* `bms_soc_floor` - the pack's own cut-out (register 0x0560 on the reference
|
||||||
|
unit, 10 %). It is here so validate.py rule 8 can REFUSE a `peak_soc_floor`
|
||||||
|
at or below it. That configuration is CFG-01 rule (a): the operator states
|
||||||
|
a floor, the BMS enforces a different one, and the status page reports an
|
||||||
|
energy backstop that can never bind.
|
||||||
|
|
||||||
|
Both defaults are mirrored ONCE, in `PEAK_OPTION_DEFAULTS` below, which is
|
||||||
|
TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern - imported by validate.py and by
|
||||||
|
main.py, pinned to config.yaml by test_peakguard.py rather than transcribed.
|
||||||
|
This module still reads no option itself: `decide()` takes both as ARGUMENTS,
|
||||||
|
makes no `.get()` call, and the mirror is a default table, not a read.
|
||||||
|
|
||||||
|
⚠️ A DECISION RECORDED, NOT A RENAME DONE. Now that a second SoC floor exists,
|
||||||
|
`maintenance_soc_floor` is the ambiguous name of the two - it reads like "the
|
||||||
|
SoC floor" and is not. Renaming it is an option migration on a live install for
|
||||||
|
a clarity gain, so the decision recorded here is: KEEP IT, and carry the
|
||||||
|
distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||||
|
a loop-side guard would be the third - at which point the migration buys enough
|
||||||
|
to be worth its cost.
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||||
@@ -287,8 +368,44 @@ BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
|||||||
CEILING_CAP = "peak_cap_w"
|
CEILING_CAP = "peak_cap_w"
|
||||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||||
|
|
||||||
|
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||||
|
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||||
|
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||||
|
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||||
|
"cycle, and an unreadable SoC is not a full battery")
|
||||||
|
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||||
|
|
||||||
SOURCE = "peak"
|
SOURCE = "peak"
|
||||||
|
|
||||||
|
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and
|
||||||
|
# it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the
|
||||||
|
# add-on already carries option defaults in three places (config.yaml's
|
||||||
|
# `options:`, config.yaml's `schema:`, and main.py's literal `.get()`
|
||||||
|
# fallbacks), and a fourth is the open ticket. validate.py and main.py both
|
||||||
|
# import THIS dict rather than typing a literal, and test_peakguard.py pins it
|
||||||
|
# to config.yaml by READING the file - so a drift goes red instead of shipping.
|
||||||
|
#
|
||||||
|
# ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes
|
||||||
|
# no `.get()` call at all; the dict is a default table for its two callers.
|
||||||
|
PEAK_OPTION_DEFAULTS = {
|
||||||
|
# The EMS-side floor on SAFETY-07's forced discharge, in percent.
|
||||||
|
#
|
||||||
|
# 20 is DERIVED from the two numbers this repository already has, not
|
||||||
|
# chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh
|
||||||
|
# usable, the figure validate.py's rule 6 note already quotes - and the
|
||||||
|
# 2026-08-20 replay models 878 Wh of guard discharge in a day, which is
|
||||||
|
# 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out
|
||||||
|
# therefore leaves the guard roughly 1.6 of its worst observed days of room
|
||||||
|
# before it stops, which is the smallest margin that lets the guard do its
|
||||||
|
# job on a bad day and still never reach the BMS.
|
||||||
|
"peak_soc_floor": 20,
|
||||||
|
# The pack's OWN cut-out, in percent: what the BMS enforces regardless of
|
||||||
|
# anything here. On the reference unit this is register 0x0560, read as 10,
|
||||||
|
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
|
||||||
|
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
|
||||||
|
"bms_soc_floor": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PeakDecision:
|
class PeakDecision:
|
||||||
@@ -309,9 +426,19 @@ class PeakDecision:
|
|||||||
excess_w: float = 0.0
|
excess_w: float = 0.0
|
||||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||||
saturated: bool = False
|
saturated: bool = False
|
||||||
|
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
|
||||||
|
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||||
|
# construction: this branch asks for nothing, so it cannot have run out of
|
||||||
|
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||||
|
floor_limited: bool = False
|
||||||
|
soc: float | None = None
|
||||||
|
soc_floor: float | None = None
|
||||||
|
soc_floor_reason: str | None = None
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
def explain(self) -> str:
|
def explain(self) -> str:
|
||||||
|
if self.floor_limited:
|
||||||
|
return self.reason
|
||||||
if not self.engaged:
|
if not self.engaged:
|
||||||
if self.projected_w is None:
|
if self.projected_w is None:
|
||||||
return f"peak guard idle - {self.reason}"
|
return f"peak guard idle - {self.reason}"
|
||||||
@@ -383,7 +510,44 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||||
|
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||||
|
|
||||||
|
Returns None when it may, and otherwise the REASON it may not - which the
|
||||||
|
decision, the status page, the action log and the operator notification all
|
||||||
|
carry, because "floor-limited" on its own tells nobody what to do.
|
||||||
|
|
||||||
|
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||||
|
`freeze.looks_frozen()`, `watchdog.loop_is_following()` and
|
||||||
|
`shaping_suspended()` are: a safety predicate transcribed into its own check
|
||||||
|
is one copy plus a future disagreement about what the check means, and the
|
||||||
|
copy that drifts is always the grading one.
|
||||||
|
|
||||||
|
⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and
|
||||||
|
a FULL BATTERY is exactly the value that would disarm this floor - so a
|
||||||
|
`float(soc or 100)` here would restore the unbounded discharge this card
|
||||||
|
removes, silently, on the one install whose SoC sensor is misnamed. There is
|
||||||
|
deliberately no default argument on either parameter.
|
||||||
|
|
||||||
|
⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one
|
||||||
|
step further out: a caller that has no floor configured has not thereby been
|
||||||
|
granted an unbounded one.
|
||||||
|
|
||||||
|
Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already
|
||||||
|
spent everything this guard was allowed to spend; permitting one more cycle
|
||||||
|
there would make the floor mean "one cycle below the floor".
|
||||||
|
"""
|
||||||
|
if soc is None:
|
||||||
|
return FLOOR_SOC_UNREADABLE
|
||||||
|
if soc_floor is None:
|
||||||
|
return ("no peak guard SoC floor is configured, so there is nothing to "
|
||||||
|
"bound a forced discharge with")
|
||||||
|
if float(soc) <= float(soc_floor):
|
||||||
|
return FLOOR_AT_SOC_FLOOR
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||||
|
|
||||||
@@ -395,6 +559,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
|||||||
claim can NEVER produce `lo > hi` in the resolver - a
|
claim can NEVER produce `lo > hi` in the resolver - a
|
||||||
contradiction there resolves to 0 W, which would park the
|
contradiction there resolves to 0 W, which would park the
|
||||||
battery in the exact minute it should be discharging hardest.
|
battery in the exact minute it should be discharging hardest.
|
||||||
|
`soc` SAFETY-24. MEASURED state of charge, percent. None when the
|
||||||
|
sensor for it did not answer - which is floor-limited, not
|
||||||
|
permission.
|
||||||
|
`soc_floor` SAFETY-24. The percent below which this guard may not spend
|
||||||
|
the pack for money. REQUIRED, and deliberately without a
|
||||||
|
default: a default here would be CFG-03's fourth defaults
|
||||||
|
site and TEST-14's quiet fallback in one line.
|
||||||
"""
|
"""
|
||||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||||
ceil_top = abs(float(achievable_w))
|
ceil_top = abs(float(achievable_w))
|
||||||
@@ -454,12 +625,41 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
|||||||
wanted_w=wanted,
|
wanted_w=wanted,
|
||||||
reason="the load is under the level this quarter can afford")
|
reason="the load is under the level this quarter can afford")
|
||||||
|
|
||||||
|
# SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced
|
||||||
|
# discharge really is being asked for and the SoC is the quantity that
|
||||||
|
# decides whether it may be sized. Asked one line earlier it would grade a
|
||||||
|
# number that governs nothing on every quiet cycle of the day, and would
|
||||||
|
# report "floor-limited" over a guard that was never going to discharge.
|
||||||
|
denied = discharge_permitted(soc, soc_floor)
|
||||||
|
if denied is not None:
|
||||||
|
# 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING -
|
||||||
|
# one-directional, cannot cost pack energy - and forbids nothing else;
|
||||||
|
# a `lo` limit structurally cannot cap the discharge the loop is already
|
||||||
|
# doing. See the module note: this bounds THE GUARD's forced discharge,
|
||||||
|
# not the grid-following loop, which still has no SoC guard.
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=True, floor_w=0.0, ceiling_w=ceiling,
|
||||||
|
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||||
|
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||||
|
soc_floor_reason=denied,
|
||||||
|
reason=(
|
||||||
|
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||||
|
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||||
|
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||||
|
f"it, but {denied}"
|
||||||
|
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||||
|
+ ("" if soc is None or soc_floor is None
|
||||||
|
else f" vs {float(soc_floor):.0f} %")
|
||||||
|
+ ("" if soc is None else ")")
|
||||||
|
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||||
|
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||||
|
|
||||||
floor = min(wanted, ceil_top)
|
floor = min(wanted, ceil_top)
|
||||||
saturated = floor < wanted
|
saturated = floor < wanted
|
||||||
return PeakDecision(
|
return PeakDecision(
|
||||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||||
saturated=saturated,
|
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||||
reason=(
|
reason=(
|
||||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||||
@@ -513,5 +713,13 @@ def claim(decision: PeakDecision) -> Claim | None:
|
|||||||
"""
|
"""
|
||||||
if not decision.engaged or decision.floor_w is None:
|
if not decision.engaged or decision.floor_w is None:
|
||||||
return None
|
return None
|
||||||
|
# Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE
|
||||||
|
# different states now - blind, floor-limited, and a genuine floor that
|
||||||
|
# happens to be zero - and the arbiter's explain() is what an operator reads
|
||||||
|
# when the battery stops charging. Spelling them the same way would make the
|
||||||
|
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
|
||||||
|
# that gets read.
|
||||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||||
reason=("blind" if decision.blind else "quarter peak"))
|
reason=("blind" if decision.blind
|
||||||
|
else "SoC floor" if decision.floor_limited
|
||||||
|
else "quarter peak"))
|
||||||
|
|||||||
@@ -282,6 +282,87 @@ def volatility_regime(product: PriceProduct) -> str:
|
|||||||
return "wide" if product in _WIDE else "normal"
|
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)
|
@dataclass(frozen=True)
|
||||||
class PricePoint:
|
class PricePoint:
|
||||||
"""One canonical point. THREE FIELDS. §9.3 lists three; this has three."""
|
"""One canonical point. THREE FIELDS. §9.3 lists three; this has three."""
|
||||||
@@ -526,6 +607,26 @@ PRICE_OPTION_DEFAULTS = {
|
|||||||
# test_price.py section 1. A second defaults dict in the new module would be
|
# test_price.py section 1. A second defaults dict in the new module would be
|
||||||
# a fourth place option defaults live, which is exactly the open ticket.
|
# a fourth place option defaults live, which is exactly the open ticket.
|
||||||
"static_profile": "",
|
"static_profile": "",
|
||||||
|
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
|
||||||
|
#
|
||||||
|
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
|
||||||
|
# FCST-03's note above `WIDEN_FACTOR` names this as the place they would
|
||||||
|
# land. `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are the reserve
|
||||||
|
# arbitrage may not spend and the headroom it may not overfill; they are
|
||||||
|
# NOT the maintenance SoC keys, which describe a balancing cycle rather
|
||||||
|
# than a trading reserve and would silently couple two unrelated policies.
|
||||||
|
#
|
||||||
|
# ⚠️ AND `cycle_cost_threshold_eur` IS 0.10 BECAUSE §10.1's NAME FOR IT SAYS
|
||||||
|
# SO, NOT BECAUSE IT MAKES ANYTHING TRADE. On realistic Belgian day/night
|
||||||
|
# numbers the static spread is ~0.08 EUR/kWh, so a static-tariff site does
|
||||||
|
# NOT trade at this default. That is a reported result (test_arbitrage.py
|
||||||
|
# section 7), and whether 0.10 - or FCST-03's 2.5/1.5 - is right is one
|
||||||
|
# product decision that belongs to the owner. No number here was chosen to
|
||||||
|
# make arbitrage happen.
|
||||||
|
"arbitrage_enabled": False,
|
||||||
|
"cycle_cost_threshold_eur": 0.10,
|
||||||
|
"arbitrage_soc_floor": 20,
|
||||||
|
"arbitrage_soc_ceiling": 95,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
|
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
|
||||||
|
|||||||
@@ -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.
|
from a refusal, because it cannot get the inputs out of one.
|
||||||
|
|
||||||
And the verdict is only obtainable from `TariffPlan.arbitrage(...)`, which takes
|
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
|
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
|
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
|
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
|
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.
|
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
|
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
|
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
|
3. fewer than TWO FIRM HOURS §8.3. FCST-03 rewrote this one: it used
|
||||||
4. the series is FLAT max - min == 0, so there is nothing to
|
to be "any point in the series is
|
||||||
arbitrage. THIS is AC2: a fully-fixed
|
provisional", which would have switched
|
||||||
tariff disables arbitrage by ARITHMETIC,
|
arbitrage off every morning on every
|
||||||
not by a special case keyed on
|
dynamic site once carry-forward filling
|
||||||
`PriceProduct.FIXED`. A dynamic feed
|
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
|
that happens to print one price all day
|
||||||
gets refused for the same true reason.
|
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
|
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
|
`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 (
|
from .price import (
|
||||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
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")
|
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||||
@@ -619,8 +644,9 @@ class Arbitrage(_Outcome):
|
|||||||
return {"allowed": allowed, "refused": refused}[self.kind](self)
|
return {"allowed": allowed, "refused": refused}[self.kind](self)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def allow(spread, cheapest, dearest, regime, product):
|
def allow(spread, cheapest, dearest, regime, product, required, sources):
|
||||||
return _Allowed(spread, cheapest, dearest, regime, product)
|
return _Allowed(spread, cheapest, dearest, regime, product, required,
|
||||||
|
sources)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def refuse(reason):
|
def refuse(reason):
|
||||||
@@ -632,17 +658,43 @@ class Arbitrage(_Outcome):
|
|||||||
return _Refused(reason)
|
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):
|
class _Allowed(Arbitrage):
|
||||||
kind = "allowed"
|
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.spread_eur_kwh = spread
|
||||||
self.cheapest = cheapest # the PricePoint at the low end
|
self.cheapest = cheapest # the PricePoint at the low end
|
||||||
self.dearest = dearest # the PricePoint at the high end
|
self.dearest = dearest # the PricePoint at the high end
|
||||||
self.regime = regime # §8.3 "wide" | "normal"
|
self.regime = regime # §8.3 "wide" | "normal"
|
||||||
self.product = product
|
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 "
|
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):
|
class _Refused(Arbitrage):
|
||||||
@@ -724,36 +776,100 @@ class TariffPlan:
|
|||||||
no_data=lambda r: _absent(r.reason))
|
no_data=lambda r: _absent(r.reason))
|
||||||
|
|
||||||
# -- the two things a consumer is allowed to act on ----------------------
|
# -- 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:
|
def cheap_now(self, instant: datetime) -> bool:
|
||||||
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
|
"""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
|
⚠️ FALSE ON A PROVISIONAL HOUR, AND IT IS THIS HOUR THAT DECIDES
|
||||||
provisional price, and a grid top-up of a battery is about as
|
(FCST-03). §8.3 forbids an irreversible action on a provisional price
|
||||||
irreversible as this add-on gets. False when there is no price at all,
|
and a paid grid top-up of a battery is about as irreversible as this
|
||||||
which is the §11.4 row and also main.py's long-standing "fixed-tariff
|
add-on gets - but the price that would be paid is THIS hour's, so a
|
||||||
site: never force a paid grid top-up, wait for sun".
|
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
|
return False
|
||||||
try:
|
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:
|
except Absence:
|
||||||
# An instant outside the series is an hour with no price (§11.4 for
|
# An instant outside the series is an hour with no price (§11.4 for
|
||||||
# that hour), not a cheap one.
|
# that hour), not a cheap one.
|
||||||
return False
|
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.
|
"""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
|
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||||
shaving above arbitrage, and this is how that ordering survives a
|
each, and none of them has a default:
|
||||||
strategy that has not been written yet: the spread is not obtainable
|
|
||||||
without answering the question.
|
`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):
|
if not isinstance(peak_guard_active, bool):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"peak_guard_active must be a bool, not {peak_guard_active!r}. "
|
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.")
|
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:
|
if peak_guard_active:
|
||||||
return Arbitrage.refuse(
|
return Arbitrage.refuse(
|
||||||
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
|
"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"§11.4: {self.read.reason}. No series exists, so no spread "
|
||||||
f"exists; self-consumption, peak shaving and reserve management "
|
f"exists; self-consumption, peak shaving and reserve management "
|
||||||
f"(§10.1 priorities 1, 2 and 4) are unaffected")
|
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(
|
return Arbitrage.refuse(
|
||||||
f"§11.4: the plan is provisional (origin {self.origin!r}), and "
|
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
|
||||||
f"§8.3 forbids an irreversible action on a provisional price. "
|
f"price - the rest are provisional (origin {self.origin!r}), "
|
||||||
f"Load shifting and peak shaving continue")
|
f"and no irreversible action may be taken on a provisional "
|
||||||
spread = self.spread_eur_kwh()
|
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:
|
if spread == 0.0:
|
||||||
return Arbitrage.refuse(
|
return Arbitrage.refuse(
|
||||||
f"the tariff is flat - every one of the "
|
f"the tariff is flat - every one of the {len(firm)} firm hours "
|
||||||
f"{len(self.read.points)} hours costs "
|
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||||
f"{self.read.points[0].eur_per_kwh:.4f} EUR/kWh, so buying low "
|
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||||
f"and selling high is arithmetically impossible. §9.2: a fully "
|
|
||||||
f"fixed product is self-consumption only")
|
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(
|
return Arbitrage.allow(
|
||||||
spread=spread,
|
spread=spread,
|
||||||
cheapest=min(pts, key=lambda p: p.eur_per_kwh),
|
cheapest=min(firm, key=lambda p: p.eur_per_kwh),
|
||||||
dearest=max(pts, key=lambda p: p.eur_per_kwh),
|
dearest=max(firm, key=lambda p: p.eur_per_kwh),
|
||||||
regime=self.read.regime,
|
regime=self.read.regime,
|
||||||
product=self.read.product)
|
product=self.read.product,
|
||||||
|
required=need,
|
||||||
|
sources=sources)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def words(self) -> str:
|
def words(self) -> str:
|
||||||
|
|||||||
@@ -131,6 +131,25 @@ NOT READY banner already names the problem, (b) does not apply.
|
|||||||
installed base is on a fixed or static tariff. Refusing to start without a
|
installed base is on a fixed or static tariff. Refusing to start without a
|
||||||
price feed would lock those sites out of the add-on entirely.
|
price feed would lock those sites out of the add-on entirely.
|
||||||
|
|
||||||
|
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
||||||
|
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
||||||
|
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
||||||
|
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
||||||
|
own cut-out and enforces it regardless. Set the EMS floor at or below that
|
||||||
|
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
|
||||||
|
the discharge, so the add-on carries an energy backstop that is incapable
|
||||||
|
of binding while its status page reports one - rule 5's "a disarmed guard
|
||||||
|
that advertises itself as armed", on the guard whose failure mode is a pack
|
||||||
|
that is quietly never available when it is wanted.
|
||||||
|
|
||||||
|
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
||||||
|
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
||||||
|
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
|
||||||
|
limit, which is the entire thing this option exists to prevent. That is not
|
||||||
|
"close to the rail", it is the disarmed case with an extra step. Rule 6
|
||||||
|
permits equality because equality there leaves one reachable point that
|
||||||
|
works; here it leaves none.
|
||||||
|
|
||||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||||
|
|
||||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||||
@@ -151,12 +170,18 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
|||||||
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||||
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||||
not refused.
|
not refused.
|
||||||
|
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||||
|
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||||
|
every ordering of the two is a legitimate site configuration. Refusing on
|
||||||
|
an ordering would be inventing a relationship the system does not have.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .arbitrage import enabled as arbitrage_on
|
||||||
from .p1 import is_enabled, validate_meter_options
|
from .p1 import is_enabled, validate_meter_options
|
||||||
from .price import PROVIDERS
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||||
|
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.config")
|
_LOG = logging.getLogger("goodwe.config")
|
||||||
@@ -369,6 +394,36 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
# --- 7. two price sources on one site ------------------------------------
|
# --- 7. two price sources on one site ------------------------------------
|
||||||
problems.extend(_price_problems(opts))
|
problems.extend(_price_problems(opts))
|
||||||
|
|
||||||
|
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
||||||
|
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
||||||
|
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
|
||||||
|
# comment documents a LIVE divergence in this repo caused by typing 2000
|
||||||
|
# here and 2500 in main.py; this card refuses to add a second instance of
|
||||||
|
# it. The dict is the single in-code mirror, pinned to config.yaml by
|
||||||
|
# test_peakguard.py, and main.py reads the same one.
|
||||||
|
soc_floor = _number(opts, "peak_soc_floor",
|
||||||
|
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||||
|
bms_floor = _number(opts, "bms_soc_floor",
|
||||||
|
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||||
|
if soc_floor is None or bms_floor is None:
|
||||||
|
problems.append(
|
||||||
|
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
||||||
|
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
||||||
|
elif soc_floor <= bms_floor:
|
||||||
|
problems.append(
|
||||||
|
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||||
|
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||||
|
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||||
|
"the pack's protection rather than yours. The guard would report a "
|
||||||
|
"SoC floor on its status page while being incapable of ever being "
|
||||||
|
"the thing that stops the discharge, and the symptom of that is a "
|
||||||
|
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||||
|
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||||
|
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||||
|
f"(the shipped default is "
|
||||||
|
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||||
|
"bms_soc_floor if it does not match this pack.")
|
||||||
|
|
||||||
# --- warnings: real, but not worth refusing over -------------------------
|
# --- warnings: real, but not worth refusing over -------------------------
|
||||||
integ = opts.get("integrator_max_w")
|
integ = opts.get("integrator_max_w")
|
||||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||||
@@ -453,14 +508,21 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
# arbitrage verdict, so the seam is no longer inert.
|
# arbitrage verdict, so the seam is no longer inert.
|
||||||
# * main.py reads the STATIC side of that module every cycle
|
# * main.py reads the STATIC side of that module every cycle
|
||||||
# (static_status()), which makes static_profile a real writer of `cheap`.
|
# (static_status()), which makes static_profile a real writer of `cheap`.
|
||||||
# * main.py does NOT yet call resolve_tariff() on the control tick, and
|
# * main.py does NOT call resolve_tariff() on the control tick, and that
|
||||||
# that is deliberate: a provider fetch is network I/O and the tick is
|
# is still deliberate: a provider fetch is network I/O and the tick is
|
||||||
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
|
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
|
||||||
# no energy decision on its own; a configured STATIC PROFILE does.
|
# instead (Controller.run_tariff_refresh), and the tick reads the plan
|
||||||
|
# that task last stored - an attribute read, with nothing to block on.
|
||||||
|
# * so a configured PROVIDER now DOES shape an energy decision, but only
|
||||||
|
# through app/arbitrage.py and only while `arbitrage_enabled` is on.
|
||||||
|
# With it off - the shipped configuration - the series is still fetched,
|
||||||
|
# validated and published, and still moves nothing.
|
||||||
#
|
#
|
||||||
# Hence two refusals below rather than one, and neither claims more than
|
# Hence three refusals below rather than two, and none of them claims more
|
||||||
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
|
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
|
||||||
# first message's middle clause is what changes - not the rule.
|
# CHANGED, exactly as the note it replaced predicted - the RULE is
|
||||||
|
# untouched, because the reason it exists (main.py reads the Home Assistant
|
||||||
|
# pair FIRST and the other source is silent) is unchanged.
|
||||||
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
||||||
if str(opts.get(k, "") or "").strip()]
|
if str(opts.get(k, "") or "").strip()]
|
||||||
if pid != "off" and ha_price:
|
if pid != "off" and ha_price:
|
||||||
@@ -469,10 +531,12 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
||||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
||||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
||||||
"reads. The provider series IS consumed now - app/static_tariff.py "
|
"reads, and read_inputs() never looks at the other source once "
|
||||||
"builds a tariff plan and an arbitrage verdict from it - but it is "
|
"they are set. The provider series is consumed - "
|
||||||
"not what main.py acts on, so the supplier you configured would "
|
"app/static_tariff.py builds a tariff plan and an arbitrage "
|
||||||
"shape no energy decision while the status page named it. Clear "
|
"verdict, and app/arbitrage.py trades on it off a background "
|
||||||
|
"refresh - but the `cheap` flag those entities own is a separate "
|
||||||
|
"decision they would silently keep. Clear "
|
||||||
"price_now_entity and price_avg_entity to use the "
|
"price_now_entity and price_avg_entity to use the "
|
||||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||||
"Home Assistant sensors.")
|
"Home Assistant sensors.")
|
||||||
@@ -528,6 +592,67 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||||
"but filling it in is what makes that row work.", pid)
|
"but filling it in is what makes that row work.", pid)
|
||||||
|
|
||||||
|
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||||
|
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||||
|
# fire is indistinguishable from one that fires and finds no opportunity -
|
||||||
|
# which is the §14 warning box's case exactly. None of these can break an
|
||||||
|
# existing install: the option is new and ships `false`.
|
||||||
|
# ⚠️ CHECKED WHETHER OR NOT ARBITRAGE IS ARMED. A threshold that is not a
|
||||||
|
# non-negative number is a configuration error the moment it is typed, and
|
||||||
|
# deferring the complaint until somebody turns the strategy on would report
|
||||||
|
# it at the worst possible moment - the one where energy starts moving.
|
||||||
|
thresh = _number(opts, "cycle_cost_threshold_eur",
|
||||||
|
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
||||||
|
if thresh is None or thresh < 0:
|
||||||
|
problems.append(
|
||||||
|
f"cycle_cost_threshold_eur is "
|
||||||
|
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
|
||||||
|
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
|
||||||
|
f"(§10.1). A negative threshold is one that every spread clears, "
|
||||||
|
f"including a spread of nothing, which is arbitrage with the brake "
|
||||||
|
f"removed rather than arbitrage tuned aggressively.")
|
||||||
|
|
||||||
|
if arbitrage_on(opts):
|
||||||
|
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||||
|
problems.append(
|
||||||
|
"arbitrage_enabled is on but price_provider is 'off' and "
|
||||||
|
"static_profile is empty, so this site has NO PRICES AT ALL. "
|
||||||
|
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
|
||||||
|
"there is nothing here to tell cheap from expensive, so the "
|
||||||
|
"strategy would refuse on every cycle forever while the "
|
||||||
|
"configuration said it was running. Configure a price_provider "
|
||||||
|
"or a static_profile, or turn arbitrage_enabled off.")
|
||||||
|
if not is_enabled(opts):
|
||||||
|
problems.append(
|
||||||
|
"arbitrage_enabled is on but meter_source is 'off', so there "
|
||||||
|
"is no P1 telegram stream, the capacity-tariff peak guard "
|
||||||
|
"never runs, and nothing measures the 15-minute average this "
|
||||||
|
"strategy's charging would land in. §9.1 gives peak shaving "
|
||||||
|
"priority over arbitrage; an unmeasured peak cannot be shaved, "
|
||||||
|
"so arbitrage would be deliberately creating the billed "
|
||||||
|
"quantity with the one guard that limits it switched off. "
|
||||||
|
"app/arbitrage.py refuses to trade in that state anyway - this "
|
||||||
|
"refuses the configuration that asks it to. Set meter_source, "
|
||||||
|
"or turn arbitrage_enabled off.")
|
||||||
|
floor = _number(opts, "arbitrage_soc_floor",
|
||||||
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
||||||
|
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
||||||
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
||||||
|
if floor is None or ceiling is None:
|
||||||
|
problems.append(
|
||||||
|
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
|
||||||
|
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
|
||||||
|
f"must both be numbers - they are percentages of pack charge.")
|
||||||
|
elif floor >= ceiling:
|
||||||
|
problems.append(
|
||||||
|
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
|
||||||
|
f"is {ceiling:g} %, so there is no band left between them: "
|
||||||
|
f"every state of charge is either at or under the floor (no "
|
||||||
|
f"discharge) or at or over the ceiling (no charge), and the "
|
||||||
|
f"strategy can never act. The floor is the reserve it may not "
|
||||||
|
f"spend and the ceiling is the headroom it may not overfill, "
|
||||||
|
f"so the floor must be the SMALLER number.")
|
||||||
|
|
||||||
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
||||||
|
|||||||
@@ -163,6 +163,37 @@ options:
|
|||||||
peak_forecast_entity: ""
|
peak_forecast_entity: ""
|
||||||
peak_cap_w: 3500
|
peak_cap_w: 3500
|
||||||
|
|
||||||
|
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
||||||
|
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||||
|
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
||||||
|
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
||||||
|
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
|
||||||
|
#
|
||||||
|
# peak_soc_floor is the percent below which that discharge is not sized. When
|
||||||
|
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
|
||||||
|
# floor), and the add-on raises ONE notification saying the quarter will be
|
||||||
|
# exceeded - failing to shave is never silent, because the euro figure is the
|
||||||
|
# whole point of the guard.
|
||||||
|
#
|
||||||
|
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
|
||||||
|
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
|
||||||
|
# depth the cycle deliberately goes to, once a month, on purpose. This is the
|
||||||
|
# depth a money-saving discharge may never reach on any day. The right values
|
||||||
|
# differ, and sharing one key would couple two features that want to move
|
||||||
|
# independently.
|
||||||
|
#
|
||||||
|
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
|
||||||
|
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
|
||||||
|
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
|
||||||
|
# and still never reaches the BMS.
|
||||||
|
peak_soc_floor: 20
|
||||||
|
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||||
|
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||||
|
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||||
|
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||||
|
# report an energy backstop that does not exist.
|
||||||
|
bms_soc_floor: 10
|
||||||
|
|
||||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||||
@@ -240,6 +271,38 @@ options:
|
|||||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||||
export_price_eur_kwh: 0.04
|
export_price_eur_kwh: 0.04
|
||||||
|
|
||||||
|
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
||||||
|
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
||||||
|
# when the spread covers what the round trip costs. Off in the shipped
|
||||||
|
# configuration: this is the first strategy in the add-on that moves energy
|
||||||
|
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||||
|
#
|
||||||
|
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||||
|
# combinations that cannot work rather than letting them look armed. With
|
||||||
|
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||||
|
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
|
||||||
|
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
|
||||||
|
# exact failure that guard exists to prevent.
|
||||||
|
#
|
||||||
|
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
|
||||||
|
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
|
||||||
|
# below is 0.10, so the verdict is "the firm spread does not cover this
|
||||||
|
# cycle's cost" and nothing happens - correctly. That is a product decision
|
||||||
|
# (this number, and app/price.py's widening factors) and it is open with the
|
||||||
|
# owner; the code is correct at any value and no number was picked to make a
|
||||||
|
# trade appear.
|
||||||
|
arbitrage_enabled: false
|
||||||
|
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
||||||
|
# - the wear on the pack plus the conversion losses. A spread below this is
|
||||||
|
# a cycle that loses money and pack life, so it is not taken.
|
||||||
|
cycle_cost_threshold_eur: 0.10
|
||||||
|
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
||||||
|
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
||||||
|
# balancing cycle, and pointing a trading strategy at them would couple two
|
||||||
|
# unrelated policies through one number.
|
||||||
|
arbitrage_soc_floor: 20
|
||||||
|
arbitrage_soc_ceiling: 95
|
||||||
|
|
||||||
# --- site ------------------------------------------------------------------
|
# --- site ------------------------------------------------------------------
|
||||||
estop_fitted: false
|
estop_fitted: false
|
||||||
log_level: info
|
log_level: info
|
||||||
@@ -311,6 +374,12 @@ schema:
|
|||||||
|
|
||||||
peak_forecast_entity: str?
|
peak_forecast_entity: str?
|
||||||
peak_cap_w: int(500,15000)
|
peak_cap_w: int(500,15000)
|
||||||
|
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||||
|
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||||
|
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||||
|
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||||
|
peak_soc_floor: int(5,60)
|
||||||
|
bms_soc_floor: int(0,50)
|
||||||
price_now_entity: str?
|
price_now_entity: str?
|
||||||
price_avg_entity: str?
|
price_avg_entity: str?
|
||||||
|
|
||||||
@@ -337,5 +406,15 @@ schema:
|
|||||||
# bottoms out at 0 would make that site inexpressible.
|
# bottoms out at 0 would make that site inexpressible.
|
||||||
export_price_eur_kwh: float(-1,1)
|
export_price_eur_kwh: float(-1,1)
|
||||||
|
|
||||||
|
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||||
|
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||||
|
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||||
|
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||||
|
# handed any dict at all.
|
||||||
|
arbitrage_enabled: bool
|
||||||
|
cycle_cost_threshold_eur: float(0,1)
|
||||||
|
arbitrage_soc_floor: int(5,95)
|
||||||
|
arbitrage_soc_ceiling: int(10,100)
|
||||||
|
|
||||||
estop_fitted: bool
|
estop_fitted: bool
|
||||||
log_level: list(trace|debug|info|warning|error)
|
log_level: list(trace|debug|info|warning|error)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""DATA-02: fetch 12 months of EPEX BE day-ahead prices, in §9.3's shape.
|
||||||
|
|
||||||
|
python3 scenarios/fetch_epex_be.py --start 2025-09-01 --end 2026-09-01 \
|
||||||
|
--out scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json
|
||||||
|
|
||||||
|
Run from `goodwe_controller/`. Rewrites the output file from scratch; the
|
||||||
|
committed dataset is exactly what this produces, and `--verify` re-derives every
|
||||||
|
claim the file makes about itself without going to the network.
|
||||||
|
|
||||||
|
|
||||||
|
WHAT THIS IS NOT
|
||||||
|
================
|
||||||
|
NOT wired into the add-on, and it must never be. `price.py`'s `GenericDayAhead`
|
||||||
|
is the live path and it has its own egress check; this is a one-shot archival
|
||||||
|
fetch that produces a file for REPLAY, run by hand by a developer. Nothing under
|
||||||
|
`app/` imports it.
|
||||||
|
|
||||||
|
|
||||||
|
THE SOURCE, AND WHY THIS ONE
|
||||||
|
============================
|
||||||
|
`https://api.energy-charts.info/price?bzn=BE` - Fraunhofer ISE's Energy-Charts
|
||||||
|
API. No key, no registration.
|
||||||
|
|
||||||
|
⚠️ THE LICENCE WAS CHECKED BEFORE ANY DATA WAS COMMITTED, because for this
|
||||||
|
source it is NOT uniform across bidding zones. The API's own OpenAPI document
|
||||||
|
(`https://api.energy-charts.info/openapi.json`, /price) splits the zones in two:
|
||||||
|
|
||||||
|
"The data for the following bidding zones is licensed as CC BY 4.0 from
|
||||||
|
Bundesnetzagentur | SMARD.de and is published without changes:
|
||||||
|
AT, BE (Belgium), CH, CZ, DE-LU, ..."
|
||||||
|
|
||||||
|
"The data for the other bidding zones is for private and internal use only.
|
||||||
|
The utilization of any data, whether in its raw or derived form, for
|
||||||
|
external or commercial purposes is expressly prohibited. Should you require
|
||||||
|
licensing for market-related data, please direct your inquiries to the
|
||||||
|
original data providers, including but not limited to EPEX SPOT SE."
|
||||||
|
|
||||||
|
BE is in the FIRST list, so redistribution is permitted with attribution and the
|
||||||
|
dataset is committed rather than left as a fetch script and a checksum. Every
|
||||||
|
response also carries the licence per-response in `license_info`, and this
|
||||||
|
script REFUSES to write a file if that field ever stops saying CC BY 4.0 - a
|
||||||
|
licence checked once by a human and assumed forever is a licence that changes
|
||||||
|
silently. Had BE been in the second list, the correct output of this card would
|
||||||
|
have been this script and a checksum and no data.
|
||||||
|
|
||||||
|
Attribution, carried in the dataset's own `provenance` block and reproduced
|
||||||
|
here: data CC BY 4.0, Bundesnetzagentur | SMARD.de, retrieved via Energy-Charts
|
||||||
|
(Fraunhofer ISE), https://energy-charts.info, published without changes.
|
||||||
|
|
||||||
|
Cross-checked against the upstream on 2026-08-31: SMARD's own chart_data series
|
||||||
|
`4996/DE` over 2026-04-10 matched this API's BE series on all 96 quarter-hours
|
||||||
|
with a maximum absolute difference of 0.00 EUR/MWh. "Published without changes"
|
||||||
|
is a claim worth checking once; `--verify` does not repeat it, because it is a
|
||||||
|
claim about the SOURCE and not about the file.
|
||||||
|
|
||||||
|
|
||||||
|
RAW WHOLESALE, AND THE ASSUMPTION THAT IS NOT BAKED IN
|
||||||
|
======================================================
|
||||||
|
⚠️ WHAT COMES BACK IS RAW WHOLESALE, EUR/MWh, DAY-AHEAD AUCTION CLEARING PRICE.
|
||||||
|
It is NOT all-in: no supplier markup, no distribution or transmission tariff, no
|
||||||
|
levies, no VAT. §9.3's canonical point is all-in, and TARIFF-02's whole trap is
|
||||||
|
a figure that already contains a component being handed to something that adds
|
||||||
|
it again.
|
||||||
|
|
||||||
|
So this file does NOT add one. `eur_per_kwh` in the output is the wholesale
|
||||||
|
figure converted from EUR/MWh, `provenance.all_in` is `false` beside it, and the
|
||||||
|
markup/tax assumption a consumer needs lives in `provenance.all_in_assumption`
|
||||||
|
as NUMBERS TO APPLY rather than numbers already applied. test_arbitrage.py
|
||||||
|
section 7 applies them where a reader can watch it happen.
|
||||||
|
|
||||||
|
The assumption's SHAPE is the part that is not arbitrary, and it decides which
|
||||||
|
conclusions depend on the guessed numbers at all:
|
||||||
|
|
||||||
|
all_in(h) = (wholesale(h) + markup + network_and_levies) * (1 + vat)
|
||||||
|
|
||||||
|
* The two ADDITIVE terms cancel out of a spread: max - min is unchanged by
|
||||||
|
them. So how often a day clears `cycle_cost_threshold_eur` does NOT depend
|
||||||
|
on the markup guess - only on the VAT multiplier.
|
||||||
|
* They do NOT cancel out of §17's negative-price guard, which compares ONE
|
||||||
|
hour's all-in price against zero. A wholesale hour at -0.02 EUR/kWh is
|
||||||
|
comfortably positive all-in once ~0.12 of fixed charges is added.
|
||||||
|
|
||||||
|
Both are asserted arithmetically in section 7 rather than believed, and the
|
||||||
|
trade count is reported on the raw spread as well, so a reader can see exactly
|
||||||
|
how much of the answer rests on the guess.
|
||||||
|
|
||||||
|
The numbers themselves (2026 Belgian residential dynamic contract, order of
|
||||||
|
magnitude) are a STATED GUESS, labelled as one everywhere they appear. They are
|
||||||
|
not a measurement and this card had nothing to measure them with.
|
||||||
|
|
||||||
|
|
||||||
|
HOURS, QUARTER-HOURS, AND THE SEAM IN THE MIDDLE OF THE YEAR
|
||||||
|
============================================================
|
||||||
|
⚠️ THE SOURCE CHANGES RESOLUTION PART-WAY THROUGH ANY RECENT 12 MONTHS. EPEX
|
||||||
|
day-ahead moved to a 15-minute market time unit in October 2025 and the feed
|
||||||
|
follows: 2025-09-10 comes back as 24 hourly points, 2025-12-10 as 96
|
||||||
|
quarter-hourly ones. An importer that assumed either one silently loses three
|
||||||
|
quarters of the year, or reads a quarter as an hour.
|
||||||
|
|
||||||
|
The canonical format is hourly, so quarter-hours are aggregated: an hour's price
|
||||||
|
is the unweighted mean of its four quarters. That is a STATED APPROXIMATION of
|
||||||
|
the hourly product, not the hourly product, and it errs in the direction that
|
||||||
|
matters here - averaging discards intra-hour variation, so the observed spread
|
||||||
|
is a LOWER bound on what a 15-minute strategy would see.
|
||||||
|
`provenance.resolution` records how many hours came from each source shape.
|
||||||
|
|
||||||
|
⚠️ AND A PARTIAL HOUR IS DROPPED, NOT AVERAGED OVER WHAT ARRIVED. An hour with
|
||||||
|
three of four quarters is an hour nobody published a complete price for; the
|
||||||
|
mean of three would be an invented number carrying no flag. Dropped hours are
|
||||||
|
listed in `provenance.gaps` with their timestamps, because a missing hour in a
|
||||||
|
feed is not a zero price, and a zero price is exactly the value that would make
|
||||||
|
everything look free.
|
||||||
|
|
||||||
|
|
||||||
|
SIZE: WHY THIS IS PLAIN JSON AND NOT A `.gz`, MEASURED
|
||||||
|
======================================================
|
||||||
|
The directory's existing captures are gzipped, so the default would have been to
|
||||||
|
gzip this too. Measured on the committed file instead of assumed:
|
||||||
|
|
||||||
|
working tree, plain JSON 893 171 bytes
|
||||||
|
what GIT actually stores for it (zlib, loose object) 74 590 bytes
|
||||||
|
what git would store for a committed `.json.gz` 56 504 bytes
|
||||||
|
|
||||||
|
⚠️ GIT ALREADY COMPRESSES BLOBS, so gzipping buys 18 KB - 24 % of an object
|
||||||
|
that is already 75 KB, in a repository whose three existing P1 captures cost
|
||||||
|
27 KB, 136 KB and 140 KB. Eighteen kilobytes is not worth making the provenance
|
||||||
|
block unreadable in a pull request, and the licence claim in that block is
|
||||||
|
precisely the thing a reviewer of this card has to read and check. Plain JSON
|
||||||
|
also greps, diffs line-by-line when a future refetch changes something, and is
|
||||||
|
what `--verify` opens. `load()` still handles `.gz` for a future dataset large
|
||||||
|
enough to want it; this one is not.
|
||||||
|
|
||||||
|
The P1 captures are gzipped for the opposite reason and it is not an
|
||||||
|
inconsistency: they are hundreds of thousands of telegram lines that no human
|
||||||
|
reads, and there is no metadata in them to review.
|
||||||
|
|
||||||
|
|
||||||
|
TIME
|
||||||
|
====
|
||||||
|
Every instant is carried as UTC and rendered with `app.price.brussels()`, the
|
||||||
|
module that already owns the EU DST rule without a tz database. There is no
|
||||||
|
second answer to that question here and there must not be: the point of
|
||||||
|
`brussels()` is that the 02:00-03:00 hour which happens twice in October is two
|
||||||
|
instants, and a 24-slot local-hour importer collapses them.
|
||||||
|
|
||||||
|
So a year is 8760 hours only if it contains no transition, or contains both and
|
||||||
|
they cancel - 25 in October, 23 in March. `--verify` therefore asserts the
|
||||||
|
PER-DAY counts, not the total, because the total is exactly the number a broken
|
||||||
|
importer also produces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.price import brussels # noqa: E402
|
||||||
|
|
||||||
|
API = "https://api.energy-charts.info/price"
|
||||||
|
BZN = "BE"
|
||||||
|
LICENCE_MUST_CONTAIN = "CC BY 4.0"
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
ATTRIBUTION = ("Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, "
|
||||||
|
"retrieved via the Energy-Charts API (Fraunhofer ISE), "
|
||||||
|
"https://energy-charts.info. Published without changes.")
|
||||||
|
|
||||||
|
# ⚠️ A GUESS, LABELLED AS ONE, AND APPLIED BY THE CONSUMER - NOT HERE.
|
||||||
|
# Order-of-magnitude 2026 Belgian residential dynamic contract. Nothing in this
|
||||||
|
# repository measured any part of it.
|
||||||
|
ALL_IN_ASSUMPTION = {
|
||||||
|
"form": "all_in = (wholesale + markup + network_and_levies) * (1 + vat)",
|
||||||
|
"markup_eur_per_kwh": 0.020,
|
||||||
|
"network_and_levies_eur_per_kwh": 0.100,
|
||||||
|
"vat_fraction": 0.06,
|
||||||
|
"status": "ASSUMPTION, NOT MEASURED. Stated so a consumer applies it "
|
||||||
|
"deliberately; the committed eur_per_kwh has none of it applied.",
|
||||||
|
"note": "The two additive terms cancel out of any spread, so an arbitrage "
|
||||||
|
"trade count depends on vat_fraction only. They do NOT cancel out "
|
||||||
|
"of the negative-price guard (17), which compares one hour's "
|
||||||
|
"all-in price against zero.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PACE_S = 3.0 # between chunks, unconditionally
|
||||||
|
RETRIES = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str) -> dict:
|
||||||
|
"""One GET, backing off on 429 and 5xx.
|
||||||
|
|
||||||
|
⚠️ THIS IS A FREE PUBLIC SERVICE AND IT RATE-LIMITS. The first run of this
|
||||||
|
script was refused with 429 on its fifth chunk. Backing off is the polite
|
||||||
|
behaviour; it is also the correct one, because the alternative - catching
|
||||||
|
the error and carrying on - would produce a file that is silently missing a
|
||||||
|
month and says nothing about it. A refused request is not an empty month.
|
||||||
|
"""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "goodwe-addon/DATA-02 (one-shot archival fetch)"})
|
||||||
|
wait = 5.0
|
||||||
|
for attempt in range(1, RETRIES + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code not in (429, 500, 502, 503, 504) or attempt == RETRIES:
|
||||||
|
raise
|
||||||
|
print(f" HTTP {e.code}; backing off {wait:.0f}s "
|
||||||
|
f"(attempt {attempt}/{RETRIES})", file=sys.stderr)
|
||||||
|
time.sleep(wait)
|
||||||
|
wait *= 2
|
||||||
|
raise SystemExit(f"unreachable: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def local_midnight_utc(day: str) -> datetime:
|
||||||
|
"""The INSTANT at which Brussels midnight begins on `day` (YYYY-MM-DD).
|
||||||
|
|
||||||
|
⚠️ THE WINDOW IS LOCAL, NOT UTC, AND THE FIRST RUN OF THIS SCRIPT PROVED WHY.
|
||||||
|
Bounding it at 00:00 UTC starts the year at 02:00 CEST, which makes the first
|
||||||
|
Brussels day 22 hours long - and 22 is not one of the two lengths a
|
||||||
|
transition produces, so `report()` refused the file. A dataset whose first
|
||||||
|
day is short is a dataset whose "days per year" arithmetic is quietly wrong
|
||||||
|
everywhere downstream.
|
||||||
|
|
||||||
|
Derived from `brussels()` by trying the only two offsets Belgium has and
|
||||||
|
keeping the one that round-trips, rather than by restating the EU rule here.
|
||||||
|
"""
|
||||||
|
for hours in (1, 2):
|
||||||
|
guess = datetime.fromisoformat(f"{day}T00:00:00").replace(
|
||||||
|
tzinfo=timezone(timedelta(hours=hours))).astimezone(UTC)
|
||||||
|
if brussels(guess).isoformat() == f"{day}T00:00:00+0{hours}:00":
|
||||||
|
return guess
|
||||||
|
raise SystemExit(f"no Brussels midnight for {day!r} - brussels() disagrees "
|
||||||
|
f"with itself, which is a bug in price.py, not here.")
|
||||||
|
|
||||||
|
|
||||||
|
def _months(start: datetime, end: datetime):
|
||||||
|
"""[start, end) in month-sized chunks, so a bad chunk names its own window."""
|
||||||
|
a = start
|
||||||
|
while a < end:
|
||||||
|
b = min(end, (a.replace(day=1) + timedelta(days=32)).replace(day=1))
|
||||||
|
yield a, b
|
||||||
|
a = b
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(start: datetime, end: datetime):
|
||||||
|
"""Raw {unix_second: EUR/MWh} over [start, end), deduplicated.
|
||||||
|
|
||||||
|
⚠️ A `null` price is DROPPED, never coerced. The API returns null for an
|
||||||
|
interval it holds no result for, and `or 0.0` is the version of this line
|
||||||
|
that ships a year of free electricity.
|
||||||
|
"""
|
||||||
|
seen, licences, nulls = {}, Counter(), 0
|
||||||
|
for a, b in _months(start, end):
|
||||||
|
# ⚠️ THE API'S `end` IS AN INCLUSIVE LOCAL DAY, AND THE WINDOW'S IS AN
|
||||||
|
# EXCLUSIVE INSTANT. Ask for the day containing the LAST hour of the
|
||||||
|
# chunk, derived from the instant rather than by subtracting a calendar
|
||||||
|
# day from the bound: `b - 1 day` drops the final local day whenever the
|
||||||
|
# window ends at local midnight, which is exactly how this window ends.
|
||||||
|
# The first run lost 2026-08-31 that way, silently - 8736 points instead
|
||||||
|
# of 8760 - and the points-plus-gaps accounting in report() is what
|
||||||
|
# noticed. A day nobody asked for is not a day with no prices.
|
||||||
|
first = brussels(a).date().isoformat()
|
||||||
|
last = brussels(b - timedelta(hours=1)).date().isoformat()
|
||||||
|
url = f"{API}?bzn={BZN}&start={first}&end={last}"
|
||||||
|
d = _get(url)
|
||||||
|
lic = str(d.get("license_info", ""))
|
||||||
|
licences[lic] += 1
|
||||||
|
if LICENCE_MUST_CONTAIN not in lic:
|
||||||
|
raise SystemExit(
|
||||||
|
f"REFUSING TO WRITE. {url} came back under licence {lic!r}, "
|
||||||
|
f"which does not say {LICENCE_MUST_CONTAIN!r}. The BE zone was "
|
||||||
|
f"redistributable when this card ran; if that has changed, the "
|
||||||
|
f"right output is this script and a checksum, not the data.")
|
||||||
|
unit = str(d.get("unit", ""))
|
||||||
|
if "MWh" not in unit:
|
||||||
|
raise SystemExit(
|
||||||
|
f"REFUSING TO WRITE. {url} reports unit {unit!r}, not EUR/MWh. "
|
||||||
|
f"A silent unit change is a factor of 1000 on every price.")
|
||||||
|
for t, p in zip(d["unix_seconds"], d["price"]):
|
||||||
|
if p is None:
|
||||||
|
nulls += 1
|
||||||
|
continue
|
||||||
|
if start.timestamp() <= t < end.timestamp():
|
||||||
|
seen[int(t)] = float(p)
|
||||||
|
print(f" {a.date()} .. {b.date()} {len(d['price']):5d} intervals",
|
||||||
|
file=sys.stderr)
|
||||||
|
time.sleep(PACE_S)
|
||||||
|
return seen, licences, nulls
|
||||||
|
|
||||||
|
|
||||||
|
def to_hourly(raw: dict):
|
||||||
|
"""Hourly means keyed by UTC hour. Returns (hours, gaps, shape_counts).
|
||||||
|
|
||||||
|
An hour is emitted only when every interval the source used for it arrived:
|
||||||
|
1 of 1 where the source was hourly, 4 of 4 where it was quarter-hourly.
|
||||||
|
Anything else is a gap and is named as one.
|
||||||
|
"""
|
||||||
|
buckets = defaultdict(dict)
|
||||||
|
for t, p in raw.items():
|
||||||
|
buckets[t - (t % 3600)][t % 3600] = p
|
||||||
|
|
||||||
|
hours, gaps, shapes = {}, [], Counter()
|
||||||
|
for h in sorted(buckets):
|
||||||
|
got = buckets[h]
|
||||||
|
if set(got) == {0}:
|
||||||
|
shapes["hourly"] += 1
|
||||||
|
hours[h] = got[0]
|
||||||
|
elif set(got) == {0, 900, 1800, 2700}:
|
||||||
|
shapes["quarter_hourly_mean"] += 1
|
||||||
|
hours[h] = sum(got.values()) / 4.0
|
||||||
|
else:
|
||||||
|
gaps.append({
|
||||||
|
"hour_utc": datetime.fromtimestamp(h, UTC).isoformat(),
|
||||||
|
"intervals_present": sorted(got),
|
||||||
|
"why": "incomplete hour - dropped rather than averaged over "
|
||||||
|
"what arrived; a partial read is not an observation",
|
||||||
|
})
|
||||||
|
return hours, gaps, shapes
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(hours: dict) -> list:
|
||||||
|
"""9.3 points. THREE FIELDS, rendered at the Brussels offset for the instant."""
|
||||||
|
return [{"start": brussels(datetime.fromtimestamp(h, UTC)).isoformat(),
|
||||||
|
"eur_per_kwh": round(hours[h] / 1000.0, 6) + 0.0,
|
||||||
|
"provisional": False}
|
||||||
|
for h in sorted(hours)]
|
||||||
|
|
||||||
|
|
||||||
|
def expected_hours(start: datetime, end: datetime) -> int:
|
||||||
|
"""Whole hours in the window. Derived, rather than 8760 typed in."""
|
||||||
|
return int((end - start).total_seconds() // 3600)
|
||||||
|
|
||||||
|
|
||||||
|
def per_local_day(points) -> Counter:
|
||||||
|
"""Points per BRUSSELS calendar day - the count that exposes a DST bug."""
|
||||||
|
n = Counter()
|
||||||
|
for p in points:
|
||||||
|
n[p["start"][:10]] += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def digest(points) -> str:
|
||||||
|
"""A checksum over the POINTS, so reformatting the envelope does not move it."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for p in points:
|
||||||
|
h.update(f"{p['start']}|{p['eur_per_kwh']:.6f}|{int(p['provisional'])}\n"
|
||||||
|
.encode())
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: str) -> dict:
|
||||||
|
op = gzip.open if path.endswith(".gz") else open
|
||||||
|
with op(path, "rt", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def report(doc, *, start=None, end=None) -> int:
|
||||||
|
"""Re-derive every claim the file makes about itself. Returns failures."""
|
||||||
|
pts = doc["prices"]
|
||||||
|
prov = doc["provenance"]
|
||||||
|
bad = 0
|
||||||
|
|
||||||
|
def ck(name, cond):
|
||||||
|
nonlocal bad
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
bad += 1
|
||||||
|
|
||||||
|
ck(f"the checksum over the {len(pts)} points matches the recorded one "
|
||||||
|
f"({prov['sha256_of_points'][:16]}...)",
|
||||||
|
digest(pts) == prov["sha256_of_points"])
|
||||||
|
ck("every point has exactly the three canonical fields and nothing else",
|
||||||
|
all(set(p) == {"start", "eur_per_kwh", "provisional"} for p in pts))
|
||||||
|
ck("every start carries a UTC offset, and it is +01:00 or +02:00 - a naive "
|
||||||
|
"timestamp here is a wall clock plus an assumption",
|
||||||
|
bool(pts) and all(p["start"][-6:] in ("+01:00", "+02:00") for p in pts))
|
||||||
|
ck("the series is strictly increasing in time, so no instant is duplicated",
|
||||||
|
all(datetime.fromisoformat(a["start"]) < datetime.fromisoformat(b["start"])
|
||||||
|
for a, b in zip(pts, pts[1:])))
|
||||||
|
ck("consecutive points are exactly one hour apart AS INSTANTS, so the two "
|
||||||
|
"02:00s of the October day are two hours rather than one",
|
||||||
|
all((datetime.fromisoformat(b["start"])
|
||||||
|
- datetime.fromisoformat(a["start"])) == timedelta(hours=1)
|
||||||
|
for a, b in zip(pts, pts[1:])))
|
||||||
|
|
||||||
|
days = per_local_day(pts)
|
||||||
|
odd = {d: n for d, n in days.items() if n != 24}
|
||||||
|
ck(f"exactly two Brussels days are not 24 hours long, and they are the "
|
||||||
|
f"transitions: {sorted(odd.items())}",
|
||||||
|
sorted(odd.values()) == [23, 25] and len(odd) == 2)
|
||||||
|
spring = [d for d, n in odd.items() if n == 23]
|
||||||
|
autumn = [d for d, n in odd.items() if n == 25]
|
||||||
|
ck(f"the 23-hour day {spring} is in March and the 25-hour day {autumn} is "
|
||||||
|
f"in October, which is the direction the EU rule actually runs",
|
||||||
|
len(spring) == 1 and len(autumn) == 1
|
||||||
|
and spring[0][5:7] == "03" and autumn[0][5:7] == "10")
|
||||||
|
if autumn:
|
||||||
|
twice = [p["start"] for p in pts
|
||||||
|
if p["start"].startswith(autumn[0] + "T02")]
|
||||||
|
ck(f"...and 02:00 on {autumn[0]} occurs TWICE at two different offsets "
|
||||||
|
f"{twice} - exactly the case a 24-slot local-hour array collapses",
|
||||||
|
len(twice) == 2 and twice[0][-6:] == "+02:00"
|
||||||
|
and twice[1][-6:] == "+01:00")
|
||||||
|
ck(f"...and the two carry DIFFERENT prices, so the 25th hour is a real "
|
||||||
|
f"observation and not the same one written down twice",
|
||||||
|
len({p["eur_per_kwh"] for p in pts
|
||||||
|
if p["start"].startswith(autumn[0] + "T02")}) == 2)
|
||||||
|
if spring:
|
||||||
|
ck(f"...and 02:00 on {spring[0]} does not occur at all, because that "
|
||||||
|
f"wall clock reading never happened in Belgium",
|
||||||
|
not any(p["start"].startswith(spring[0] + "T02") for p in pts))
|
||||||
|
|
||||||
|
if start and end:
|
||||||
|
want = expected_hours(start, end)
|
||||||
|
ck(f"{len(pts)} points for a window of {want} whole hours, and the "
|
||||||
|
f"{len(prov['gaps'])} recorded gaps account for the difference "
|
||||||
|
f"exactly", len(pts) + len(prov["gaps"]) == want)
|
||||||
|
|
||||||
|
# ⚠️ A ZERO IS NOT AUTOMATICALLY A DEFECT HERE, AND SAYING IT WAS WOULD BE
|
||||||
|
# THE WRONG LESSON. The Belgian market really does clear at 0.00 EUR/MWh in
|
||||||
|
# solar-glut hours; this window contains such hours. What must be impossible
|
||||||
|
# is a zero that arrived by COERCION - a dropped read turned into a free
|
||||||
|
# hour. That is excluded structurally rather than by outlawing the value:
|
||||||
|
# nulls are dropped, incomplete hours become gaps, and the accounting above
|
||||||
|
# shows points + gaps covering every hour of the window, so every emitted
|
||||||
|
# point stands for intervals the source actually published.
|
||||||
|
zeros = [p["start"] for p in pts if p["eur_per_kwh"] == 0.0]
|
||||||
|
ck(f"the {len(zeros)} hours priced at exactly zero match the count recorded "
|
||||||
|
f"at build time, so no zero appeared between the fetch and the file",
|
||||||
|
len(zeros) == prov["exact_zero_hours"])
|
||||||
|
ck("...and none of them is a coerced read: every emitted hour is backed by "
|
||||||
|
"published intervals, which is what the points-plus-gaps accounting "
|
||||||
|
"above establishes",
|
||||||
|
len(prov["gaps"]) == 0 or all("intervals_present" in g
|
||||||
|
for g in prov["gaps"]))
|
||||||
|
ck("the figures are declared RAW WHOLESALE, and the all-in assumption is "
|
||||||
|
"recorded as an assumption rather than applied",
|
||||||
|
prov["all_in"] is False
|
||||||
|
and "ASSUMPTION" in prov["all_in_assumption"]["status"])
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--start", default="2025-09-01")
|
||||||
|
ap.add_argument("--end", default="2026-09-01",
|
||||||
|
help="exclusive; both bounds are BRUSSELS-local midnights")
|
||||||
|
ap.add_argument("--out", default=None)
|
||||||
|
ap.add_argument("--verify", default=None,
|
||||||
|
help="re-derive the claims of an existing file; no network")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
if a.verify:
|
||||||
|
doc = load(a.verify)
|
||||||
|
print(f"verifying {a.verify}")
|
||||||
|
s = datetime.fromisoformat(doc["provenance"]["window_utc"]["start"])
|
||||||
|
e = datetime.fromisoformat(doc["provenance"]["window_utc"]["end"])
|
||||||
|
bad = report(doc, start=s, end=e)
|
||||||
|
print("OK" if not bad else f"{bad} FAILED")
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
start = local_midnight_utc(a.start)
|
||||||
|
end = local_midnight_utc(a.end)
|
||||||
|
out = a.out or f"scenarios/epex-be-dayahead-{a.start}_{a.end}.json"
|
||||||
|
|
||||||
|
print(f"fetching {BZN} day-ahead {start.date()} .. {end.date()}",
|
||||||
|
file=sys.stderr)
|
||||||
|
raw, licences, nulls = fetch(start, end)
|
||||||
|
hours, gaps, shapes = to_hourly(raw)
|
||||||
|
pts = canonical(hours)
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"provenance": {
|
||||||
|
"card": "DATA-02",
|
||||||
|
"what": "EPEX BE day-ahead auction clearing prices, hourly",
|
||||||
|
"source": f"{API}?bzn={BZN} (Energy-Charts API, Fraunhofer ISE)",
|
||||||
|
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||||
|
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||||
|
"licence_checked": "before committing anything; the API's OpenAPI "
|
||||||
|
"document lists BE among the CC BY 4.0 zones and "
|
||||||
|
"the remaining zones as private/internal use "
|
||||||
|
"only, so redistribution of THIS zone is "
|
||||||
|
"permitted with attribution",
|
||||||
|
"licence_as_returned": sorted(licences),
|
||||||
|
"attribution": ATTRIBUTION,
|
||||||
|
"cross_check": "SMARD chart_data 4996/DE over 2026-04-10 matched "
|
||||||
|
"this feed on all 96 quarter-hours, max abs "
|
||||||
|
"difference 0.00 EUR/MWh (2026-08-31)",
|
||||||
|
"retrieved_utc": datetime.now(UTC).replace(microsecond=0).isoformat(),
|
||||||
|
"fetched_by": "scenarios/fetch_epex_be.py",
|
||||||
|
"window_utc": {"start": start.isoformat(), "end": end.isoformat()},
|
||||||
|
"unit": "EUR per kWh (source publishes EUR/MWh; divided by 1000)",
|
||||||
|
"all_in": False,
|
||||||
|
"all_in_note": "RAW WHOLESALE. No supplier markup, no network or "
|
||||||
|
"transmission tariff, no levies, no VAT. 9.3's "
|
||||||
|
"canonical point is all-in and this is NOT; the "
|
||||||
|
"consumer applies the assumption below, visibly.",
|
||||||
|
"all_in_assumption": ALL_IN_ASSUMPTION,
|
||||||
|
"resolution": {
|
||||||
|
"note": "EPEX day-ahead moved to a 15-minute market time unit "
|
||||||
|
"in Oct 2025, part-way through this window.",
|
||||||
|
"hours_from_hourly_source": shapes["hourly"],
|
||||||
|
"hours_from_quarter_hourly_mean": shapes["quarter_hourly_mean"],
|
||||||
|
"aggregation": "unweighted mean of the four quarters; a stated "
|
||||||
|
"approximation of the hourly product, and a "
|
||||||
|
"LOWER bound on the spread a 15-minute strategy "
|
||||||
|
"would have seen",
|
||||||
|
},
|
||||||
|
"timezone": "starts rendered at the Brussels offset by "
|
||||||
|
"app.price.brussels(); instants carried in UTC, so the "
|
||||||
|
"transition days come out 23 and 25 points by "
|
||||||
|
"construction rather than by a special case",
|
||||||
|
"nulls_dropped": nulls,
|
||||||
|
"gaps": gaps,
|
||||||
|
"gaps_note": "hours the source did not publish completely. Dropped, "
|
||||||
|
"never zero-filled: a missing hour is not a free hour.",
|
||||||
|
"points": len(pts),
|
||||||
|
"exact_zero_hours": sum(1 for p in pts if p["eur_per_kwh"] == 0.0),
|
||||||
|
"negative_hours": sum(1 for p in pts if p["eur_per_kwh"] < 0),
|
||||||
|
"zero_note": "REAL CLEARINGS AT 0.00 EUR/MWh, not dropped reads. "
|
||||||
|
"Nulls are dropped and incomplete hours become gaps, "
|
||||||
|
"so points + gaps == hours in the window and no point "
|
||||||
|
"stands in for something that failed to arrive.",
|
||||||
|
"sha256_of_points": digest(pts),
|
||||||
|
},
|
||||||
|
"prices": pts,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(out, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
json.dump(doc, f, indent=1, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"wrote {out} {len(pts)} points {os.path.getsize(out)} bytes",
|
||||||
|
file=sys.stderr)
|
||||||
|
print(f"verifying {out}")
|
||||||
|
return 1 if report(doc, start=start, end=end) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
python3 test_peakguard.py
|
python3 test_peakguard.py
|
||||||
|
|
||||||
Eight parts. What each is for, and - where it matters - what it is deliberately
|
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||||
NOT allowed to be tested at:
|
NOT allowed to be tested at:
|
||||||
|
|
||||||
1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is
|
1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is
|
||||||
@@ -36,8 +36,18 @@ NOT allowed to be tested at:
|
|||||||
6. THE REPLAY. 2026-08-20's 5029.8 W billed quarter, closed-loop, and the
|
6. THE REPLAY. 2026-08-20's 5029.8 W billed quarter, closed-loop, and the
|
||||||
healthy day's baseline. Every figure re-derived from the committed capture.
|
healthy day's baseline. Every figure re-derived from the committed capture.
|
||||||
7. THE WIRING, through the real `Controller.cycle()` and its real status page.
|
7. THE WIRING, through the real `Controller.cycle()` and its real status page.
|
||||||
8. MUTATION. Six one-line defeats of this guard, each run against the checks
|
8. SAFETY-24: THE ENERGY FLOOR under the forced discharge, graded only where
|
||||||
above to show they are not vacuous.
|
a forced discharge is actually being asked for - the SoC is a degenerate
|
||||||
|
stimulus on every cycle where `wanted <= 0`, and grading it there is how
|
||||||
|
six bugs hid in this repository this week. Includes the sweep that shows
|
||||||
|
the floor is never negative, which is the property the whole `lo`-limit
|
||||||
|
shape rests on.
|
||||||
|
9. THE EXHAUSTING REPLAY (AC 4) AND THE STARTUP REFUSAL. The same 2026-08-20
|
||||||
|
capture, against a MODELLED PACK small enough that the guard's own 878 Wh
|
||||||
|
day would take it to the BMS cut-out - so the two worlds, before and after
|
||||||
|
this card, are run against each other rather than reasoned about.
|
||||||
|
10. MUTATION. Eleven one-line defeats of these two guards, each run against
|
||||||
|
the checks above to show they are not vacuous.
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
WHAT THE REPLAY IS AND IS NOT
|
WHAT THE REPLAY IS AND IS NOT
|
||||||
@@ -66,7 +76,9 @@ support: the same quarters, with and without the guard.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ast
|
import ast
|
||||||
|
import dataclasses
|
||||||
import gzip
|
import gzip
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -89,9 +101,11 @@ from app.p1 import (
|
|||||||
)
|
)
|
||||||
from app.peakguard import (
|
from app.peakguard import (
|
||||||
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
|
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
|
||||||
CEILING_MONTH_PEAK, PeakDecision, blindness, decide, excess_w,
|
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
|
||||||
quarter_ceiling_w, uncovered_s,
|
PEAK_OPTION_DEFAULTS, PeakDecision, blindness, decide, discharge_permitted,
|
||||||
|
excess_w, quarter_ceiling_w, uncovered_s,
|
||||||
)
|
)
|
||||||
|
from app.validate import validate_startup_config
|
||||||
from app.tariff import (
|
from app.tariff import (
|
||||||
NOT_OBSERVED, NO_RECORD, OBSERVED, PeakReading, PeakStore, PeakTracker,
|
NOT_OBSERVED, NO_RECORD, OBSERVED, PeakReading, PeakStore, PeakTracker,
|
||||||
month_key,
|
month_key,
|
||||||
@@ -166,13 +180,29 @@ CYCLE_S = read_option("cycle_floor_s")
|
|||||||
# ceiling and the GW5000S-BP's rating, which is why the two agree - so the
|
# ceiling and the GW5000S-BP's rating, which is why the two agree - so the
|
||||||
# device figure is taken from the schema rather than typed.
|
# device figure is taken from the schema rather than typed.
|
||||||
LIVE_MAX_W = read_schema_max("max_w")
|
LIVE_MAX_W = read_schema_max("max_w")
|
||||||
|
# SAFETY-24's two options, out of the shipped file for the same reason.
|
||||||
|
SOC_FLOOR = read_option("peak_soc_floor")
|
||||||
|
BMS_FLOOR = read_option("bms_soc_floor")
|
||||||
|
MAINT_FLOOR = read_option("maintenance_soc_floor")
|
||||||
|
# A state of charge that comfortably permits a forced discharge, DERIVED from
|
||||||
|
# the floor rather than typed: every part of this file that is not about
|
||||||
|
# SAFETY-24 has to grade SAFETY-07's behaviour at a SoC where the new floor is
|
||||||
|
# not the thing deciding the answer, or it would be measuring the wrong guard.
|
||||||
|
SOC_OK = SOC_FLOOR + 30.0
|
||||||
|
|
||||||
print("SAFETY-07 - the quarter-peak projection guard")
|
print("SAFETY-07 - the quarter-peak projection guard")
|
||||||
|
print("SAFETY-24 - and the energy floor under its forced discharge")
|
||||||
print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, "
|
print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, "
|
||||||
f"meter_max_age_s {MAX_HOLD_S:.0f} s, meter_freeze_s {FREEZE_S:.0f} s, "
|
f"meter_max_age_s {MAX_HOLD_S:.0f} s, meter_freeze_s {FREEZE_S:.0f} s, "
|
||||||
f"cycle_floor_s {CYCLE_S:.0f} s, max_w schema ceiling {LIVE_MAX_W:.0f} W")
|
f"cycle_floor_s {CYCLE_S:.0f} s, max_w schema ceiling {LIVE_MAX_W:.0f} W")
|
||||||
|
print(f" ...and peak_soc_floor {SOC_FLOOR:.0f} %, bms_soc_floor "
|
||||||
|
f"{BMS_FLOOR:.0f} %, maintenance_soc_floor {MAINT_FLOOR:.0f} %")
|
||||||
check("every threshold this check grades against came out of config.yaml",
|
check("every threshold this check grades against came out of config.yaml",
|
||||||
None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W))
|
None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W,
|
||||||
|
SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR))
|
||||||
|
check("...and the SoC these checks use as 'plenty of battery' is comfortably "
|
||||||
|
"clear of the floor, so nothing outside part 9 is silently grading "
|
||||||
|
"SAFETY-24 instead of SAFETY-07", SOC_OK > SOC_FLOOR + 10.0)
|
||||||
|
|
||||||
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc) # a quarter boundary
|
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc) # a quarter boundary
|
||||||
|
|
||||||
@@ -239,7 +269,7 @@ BATT_NOW = 400.0
|
|||||||
a_edge = averager_at(CEIL, 450.0)
|
a_edge = averager_at(CEIL, 450.0)
|
||||||
p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450))
|
p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450))
|
||||||
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
e_edge = excess_w(p_edge, CEIL)
|
e_edge = excess_w(p_edge, CEIL)
|
||||||
print(f" held exactly AT the ceiling for 450 s: projected "
|
print(f" held exactly AT the ceiling for 450 s: projected "
|
||||||
f"{p_edge.projected_w:.2f} W, excess {e_edge:.3f} W, "
|
f"{p_edge.projected_w:.2f} W, excess {e_edge:.3f} W, "
|
||||||
@@ -254,7 +284,7 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
|
|||||||
# released as soon as `excess <= 0` hands the actuator back at the exact instant
|
# released as soon as `excess <= 0` hands the actuator back at the exact instant
|
||||||
# it is working, and the load springs straight back.
|
# it is working, and the load springs straight back.
|
||||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||||||
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
|
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
|
||||||
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
|
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
|
||||||
@@ -266,7 +296,7 @@ check("...and the claim is held there, not released. `excess` falls to zero as "
|
|||||||
d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||||
B + timedelta(seconds=450)),
|
B + timedelta(seconds=450)),
|
||||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
check("...and it DOES release once the load falls under what the quarter can "
|
check("...and it DOES release once the load falls under what the quarter can "
|
||||||
"afford, so 'threshold the floor' is not 'never let go'",
|
"afford, so 'threshold the floor' is not 'never let go'",
|
||||||
not d_gone.engaged and d_gone.floor_w is None)
|
not d_gone.engaged and d_gone.floor_w is None)
|
||||||
@@ -275,7 +305,7 @@ check("...and it DOES release once the load falls under what the quarter can "
|
|||||||
a_over = averager_at(CEIL + 200.0, 450.0)
|
a_over = averager_at(CEIL + 200.0, 450.0)
|
||||||
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
|
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
|
||||||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
print(f" 200 W over for 450 s: {d_over.explain()}")
|
print(f" 200 W over for 450 s: {d_over.explain()}")
|
||||||
check("a quarter projected over the ceiling engages, and the floor is the "
|
check("a quarter projected over the ceiling engages, and the floor is the "
|
||||||
"MEASURED battery power plus the derived excess",
|
"MEASURED battery power plus the derived excess",
|
||||||
@@ -284,7 +314,7 @@ check("a quarter projected over the ceiling engages, and the floor is the "
|
|||||||
check("...and the floor moves with the battery measurement, so it is a claim "
|
check("...and the floor moves with the battery measurement, so it is a claim "
|
||||||
"about delivered power rather than a fixed setpoint",
|
"about delivered power rather than a fixed setpoint",
|
||||||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
|
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S).floor_w,
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w,
|
||||||
d_over.floor_w, 1.0))
|
d_over.floor_w, 1.0))
|
||||||
|
|
||||||
# THE WIND-UP ARGUMENT, made concrete. A floor built on INTENT re-adds its own
|
# THE WIND-UP ARGUMENT, made concrete. A floor built on INTENT re-adds its own
|
||||||
@@ -295,7 +325,7 @@ delivered_floor = []
|
|||||||
intent_floor = []
|
intent_floor = []
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
|
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
delivered_floor.append(d_del.floor_w)
|
delivered_floor.append(d_del.floor_w)
|
||||||
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
|
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
|
||||||
intent_floor.append(intent)
|
intent_floor.append(intent)
|
||||||
@@ -378,7 +408,7 @@ absurd = averager_at(LIVE_MAX_W * 4, 870.0)
|
|||||||
p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870))
|
p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870))
|
||||||
DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w
|
DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w
|
||||||
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None,
|
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S)
|
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
|
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
|
||||||
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
|
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
|
||||||
Claim.set("loop", P_LOOP, -200.0, "grid"),
|
Claim.set("loop", P_LOOP, -200.0, "grid"),
|
||||||
@@ -461,7 +491,7 @@ print(f" a real PeakStore on a missing volume: state={rd_broken.state}, "
|
|||||||
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
|
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
|
||||||
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
|
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
|
||||||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
|
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
print(f" {d_broken.explain()}")
|
print(f" {d_broken.explain()}")
|
||||||
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
|
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
|
||||||
"the cap - 'no peak recorded' is precisely the reading that would, and it "
|
"the cap - 'no peak recorded' is precisely the reading that would, and it "
|
||||||
@@ -489,7 +519,7 @@ print("4. blindness: three ways a projection is not evidence")
|
|||||||
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
p_fresh = PeakTracker.project(fresh, B)
|
p_fresh = PeakTracker.project(fresh, B)
|
||||||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
|
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
|
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
|
||||||
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
|
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
|
||||||
check("a fresh averager's projection is 0 W, i.e. comfortably under any "
|
check("a fresh averager's projection is 0 W, i.e. comfortably under any "
|
||||||
@@ -517,7 +547,7 @@ RAILED = LIVE_MAX_W # the loop has wound to the rail
|
|||||||
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
|
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
|
||||||
FreezeWatchdogConfig(hold_s=FREEZE_S))
|
FreezeWatchdogConfig(hold_s=FREEZE_S))
|
||||||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||||
frozen=is_frozen)
|
frozen=is_frozen)
|
||||||
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
|
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
|
||||||
f"projected {p_frozen.projected_w:.0f} W, coverage "
|
f"projected {p_frozen.projected_w:.0f} W, coverage "
|
||||||
@@ -543,7 +573,7 @@ check("...and the same command NOT frozen is left alone, so the blind branch is
|
|||||||
peakguard.claim(decide(
|
peakguard.claim(decide(
|
||||||
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
|
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
|
||||||
reading=None, achievable_w=LIVE_MAX_W,
|
reading=None, achievable_w=LIVE_MAX_W,
|
||||||
max_hold_s=MAX_HOLD_S, frozen=False))
|
max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False))
|
||||||
or Claim.limit("noop", P_PEAK)]).target_w,
|
or Claim.limit("noop", P_PEAK)]).target_w,
|
||||||
-RAILED, 1e-9))
|
-RAILED, 1e-9))
|
||||||
|
|
||||||
@@ -554,7 +584,7 @@ p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600))
|
|||||||
clean = averager_at(4200.0, 600.0)
|
clean = averager_at(4200.0, 600.0)
|
||||||
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
|
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
|
||||||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
|
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
|
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
|
||||||
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
|
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
|
||||||
f"no hole")
|
f"no hole")
|
||||||
@@ -618,7 +648,7 @@ OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws")
|
|||||||
else HELD * 300.0) # acc, which the old form would still have had
|
else HELD * 300.0) # acc, which the old form would still have had
|
||||||
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
|
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
|
||||||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
|
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S)
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
|
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
|
||||||
f"(engaged={d_true.engaged}), the defaulted form's "
|
f"(engaged={d_true.engaged}), the defaulted form's "
|
||||||
f"{old_projected:.0f} W (would engage={old_projected > CAP_W})")
|
f"{old_projected:.0f} W (would engage={old_projected > CAP_W})")
|
||||||
@@ -636,24 +666,55 @@ REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
|||||||
check("the committed replay fixtures are present", len(REPLAY) >= 2)
|
check("the committed replay fixtures are present", len(REPLAY) >= 2)
|
||||||
|
|
||||||
|
|
||||||
def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W):
|
def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
|
||||||
|
soc_floor=None):
|
||||||
"""Drive one capture through the real ingest, tracker and guard.
|
"""Drive one capture through the real ingest, tracker and guard.
|
||||||
|
|
||||||
See the module docstring for the model and why its baseline is pessimistic.
|
See the module docstring for the model and why its baseline is pessimistic.
|
||||||
|
|
||||||
|
⚠️ SAFETY-24's `pack` IS A SECOND MODEL, AND IT IS STATED HERE RATHER THAN
|
||||||
|
HIDDEN. The captures carry meter power only, so the pack is simulated:
|
||||||
|
|
||||||
|
pack = (usable_wh, start_soc_pct, bms_floor_pct)
|
||||||
|
|
||||||
|
* SoC falls by the DELIVERED discharge, integrated over the real telegram
|
||||||
|
timestamps - the same integral that already produces `wh`, so the two
|
||||||
|
cannot disagree;
|
||||||
|
* the BMS is modelled as a HARD CUT at `bms_floor_pct`: at or below it the
|
||||||
|
modelled battery delivers 0 W whatever the guard asks for. Without that,
|
||||||
|
the "before this card" run would be unphysical and its comparison
|
||||||
|
worthless - the BMS really does stop the pack, and the finding this card
|
||||||
|
answers is that it is then the ONLY thing that does;
|
||||||
|
* nothing recharges the pack. One day, one direction. That is pessimistic
|
||||||
|
in the same deliberate way the zero-baseline discharge is: it isolates
|
||||||
|
what the peak guard alone spends.
|
||||||
|
|
||||||
|
`pack=None` pins the SoC at SOC_OK and reproduces SAFETY-07's numbers
|
||||||
|
exactly, which is what parts 6's existing checks grade.
|
||||||
"""
|
"""
|
||||||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||||
store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}")
|
store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}"
|
||||||
|
f"{'' if pack is None else f'-p{soc_floor:.0f}'}")
|
||||||
tracker = PeakTracker(store, cap_w=cap_w)
|
tracker = PeakTracker(store, cap_w=cap_w)
|
||||||
batt = 0.0 # delivered discharge, W (positive)
|
batt = 0.0 # delivered discharge, W (positive)
|
||||||
next_cycle = None
|
next_cycle = None
|
||||||
graded_engaged = blind_engaged = saturated = cycles = 0
|
graded_engaged = blind_engaged = saturated = cycles = 0
|
||||||
|
limited = at_bms = neg_floor = 0
|
||||||
wh = 0.0
|
wh = 0.0
|
||||||
prev_t = None
|
prev_t = None
|
||||||
|
usable_wh, soc, bms = (None, SOC_OK, None) if pack is None else (
|
||||||
|
float(pack[0]), float(pack[1]), float(pack[2]))
|
||||||
|
floor_pct = SOC_FLOOR if soc_floor is None else float(soc_floor)
|
||||||
|
min_soc = soc
|
||||||
for line in gzip.open(path, "rt", encoding="utf-8"):
|
for line in gzip.open(path, "rt", encoding="utf-8"):
|
||||||
row = json.loads(line)
|
row = json.loads(line)
|
||||||
t = datetime.fromisoformat(row["t"])
|
t = datetime.fromisoformat(row["t"])
|
||||||
if prev_t is not None:
|
if prev_t is not None:
|
||||||
wh += batt * (t - prev_t).total_seconds() / 3600.0
|
dwh = batt * (t - prev_t).total_seconds() / 3600.0
|
||||||
|
wh += dwh
|
||||||
|
if usable_wh:
|
||||||
|
soc = max(0.0, soc - 100.0 * dwh / usable_wh)
|
||||||
|
min_soc = min(min_soc, soc)
|
||||||
prev_t = t
|
prev_t = t
|
||||||
ing.submit(sample(t, float(row["w"]) - batt))
|
ing.submit(sample(t, float(row["w"]) - batt))
|
||||||
tracker.sync(ing)
|
tracker.sync(ing)
|
||||||
@@ -663,7 +724,10 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W):
|
|||||||
cycles += 1
|
cycles += 1
|
||||||
d = decide(PeakTracker.project(ing.averager, t),
|
d = decide(PeakTracker.project(ing.averager, t),
|
||||||
batt_w=batt, cap_w=cap_w, reading=tracker.peak(t),
|
batt_w=batt, cap_w=cap_w, reading=tracker.peak(t),
|
||||||
achievable_w=max_w, max_hold_s=MAX_HOLD_S, frozen=False)
|
achievable_w=max_w, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||||
|
soc_floor=floor_pct, frozen=False)
|
||||||
|
if d.floor_w is not None and d.floor_w < 0.0:
|
||||||
|
neg_floor += 1
|
||||||
if d.engaged:
|
if d.engaged:
|
||||||
(globals(), None)
|
(globals(), None)
|
||||||
if d.blind:
|
if d.blind:
|
||||||
@@ -671,15 +735,25 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W):
|
|||||||
else:
|
else:
|
||||||
graded_engaged += 1
|
graded_engaged += 1
|
||||||
saturated += 1 if d.saturated else 0
|
saturated += 1 if d.saturated else 0
|
||||||
|
limited += 1 if d.floor_limited else 0
|
||||||
|
if usable_wh and soc <= bms:
|
||||||
|
at_bms += 1
|
||||||
if not guarded:
|
if not guarded:
|
||||||
continue
|
continue
|
||||||
batt = max(0.0, d.floor_w) if (d.engaged and d.floor_w is not None) else 0.0
|
batt = max(0.0, d.floor_w) if (d.engaged and d.floor_w is not None) else 0.0
|
||||||
|
# THE BMS, MODELLED. It is a real floor and it works - that is the whole
|
||||||
|
# point of stating the risk fairly - so the "before" world is not a pack
|
||||||
|
# discharged to nothing, it is a pack pinned at the BMS limit.
|
||||||
|
if usable_wh and soc <= bms:
|
||||||
|
batt = 0.0
|
||||||
month = month_key(datetime.fromisoformat(json.loads(
|
month = month_key(datetime.fromisoformat(json.loads(
|
||||||
gzip.open(path, "rt", encoding="utf-8").readline())["t"]))
|
gzip.open(path, "rt", encoding="utf-8").readline())["t"]))
|
||||||
return {
|
return {
|
||||||
"blocks": dict(store.blocks_in(month)), "cycles": cycles,
|
"blocks": dict(store.blocks_in(month)), "cycles": cycles,
|
||||||
"graded": graded_engaged, "blind": blind_engaged,
|
"graded": graded_engaged, "blind": blind_engaged,
|
||||||
"saturated": saturated, "wh": wh, "month": month,
|
"saturated": saturated, "wh": wh, "month": month,
|
||||||
|
"limited": limited, "at_bms": at_bms, "neg_floor": neg_floor,
|
||||||
|
"end_soc": soc, "min_soc": min_soc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -872,7 +946,7 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
|||||||
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
||||||
# would mean something is wrong that is not a boundary.
|
# would mean something is wrong that is not a boundary.
|
||||||
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||||
standing_w=None):
|
standing_w=None, soc=55.0):
|
||||||
"""A real Controller driven one real cycle over a real over-limit quarter.
|
"""A real Controller driven one real cycle over a real over-limit quarter.
|
||||||
|
|
||||||
⚠️ THE LOAD IS DERIVED FROM WHERE IN THE QUARTER THE WALL CLOCK LANDS, and
|
⚠️ THE LOAD IS DERIVED FROM WHERE IN THE QUARTER THE WALL CLOCK LANDS, and
|
||||||
@@ -886,7 +960,7 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
|||||||
for attempt in range(4):
|
for attempt in range(4):
|
||||||
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
||||||
store=_Store(store) if store is not None else None)
|
store=_Store(store) if store is not None else None)
|
||||||
ctl.auto, ctl.soc, ctl.batt = True, 55.0, batt_w
|
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
|
||||||
ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||||
if standing_w is not None:
|
if standing_w is not None:
|
||||||
# The command STANDING ON THE WIRE, which is what B3 below needs to
|
# The command STANDING ON THE WIRE, which is what B3 below needs to
|
||||||
@@ -911,6 +985,9 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
|||||||
ctl.cycle())
|
ctl.cycle())
|
||||||
g = ctl.peak_guard
|
g = ctl.peak_guard
|
||||||
if g is not None and g.engaged and not g.blind:
|
if g is not None and g.engaged and not g.blind:
|
||||||
|
# ⚠️ A FLOOR-LIMITED DECISION IS A LANDING, NOT A MISS. It is
|
||||||
|
# `engaged` and not `blind`, which is what this loop is waiting for;
|
||||||
|
# SAFETY-24's own fixtures below arrive here on purpose.
|
||||||
return ctl, hs, net_w, remaining
|
return ctl, hs, net_w, remaining
|
||||||
# Only a quarter boundary crossed between the last telegram and
|
# Only a quarter boundary crossed between the last telegram and
|
||||||
# `cycle()`'s own clock gets here. Bounded at four: a fifth would mean
|
# `cycle()`'s own clock gets here. Bounded at four: a fifth would mean
|
||||||
@@ -1094,7 +1171,8 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
|||||||
# judged at a single point where they need not differ.
|
# judged at a single point where they need not differ.
|
||||||
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
||||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||||
max_hold_s=b3.p1.averager.max_hold_s)
|
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||||
|
soc_floor=SOC_FLOOR)
|
||||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||||
check("one cycle of the intent form is exactly the delivered form plus the "
|
check("one cycle of the intent form is exactly the delivered form plus the "
|
||||||
"command standing on the wire - which is the whole defect in one line: "
|
"command standing on the wire - which is the whole defect in one line: "
|
||||||
@@ -1127,14 +1205,56 @@ check("...and the consequence is the wind-up part 1 demonstrates, now on the "
|
|||||||
and not b3.peak_guard.saturated)
|
and not b3.peak_guard.saturated)
|
||||||
# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce
|
# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce
|
||||||
# it: the keyword main.py passes must be the measured attribute.
|
# it: the keyword main.py passes must be the measured attribute.
|
||||||
_call = next(n for n in ast.walk(ast.parse(_mainsrc))
|
# ⚠️ SELECTED BY QUALIFIED NAME, NOT BY BEING FIRST. This took the first
|
||||||
|
# `.decide(` in main.py and asserted there was only one - which was true when
|
||||||
|
# SAFETY-24 was written and STOPPED BEING TRUE when OPT-04 added
|
||||||
|
# `arbitrage.decide(...)`. Grading "whichever .decide() the AST walk reaches
|
||||||
|
# first" is exactly the fragility a reviewer flagged on SAFETY-07: it grades a
|
||||||
|
# position, not the call. `peakguard.decide` is what this file is about, so name
|
||||||
|
# it, and require exactly one of THAT.
|
||||||
|
_peak_calls = [n for n in ast.walk(ast.parse(_mainsrc))
|
||||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||||
and n.func.attr == "decide")
|
and n.func.attr == "decide"
|
||||||
|
and isinstance(n.func.value, ast.Name)
|
||||||
|
and n.func.value.id == "peakguard"]
|
||||||
|
_call = _peak_calls[0]
|
||||||
_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w"))
|
_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w"))
|
||||||
print(f" main.py's decide(batt_w=...) is `{_batt_src}`")
|
_soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
|
||||||
|
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
|
||||||
|
f"`{_soc_src}`")
|
||||||
check("...and the call site says so in the source: the argument is the measured "
|
check("...and the call site says so in the source: the argument is the measured "
|
||||||
"battery attribute, not the standing command and not the resolved target",
|
"battery attribute, not the standing command and not the resolved target",
|
||||||
_batt_src == "self.batt")
|
_batt_src == "self.batt")
|
||||||
|
# ⚠️ SAFETY-24, AND ON THE SAME SINGLE CALL SITE. This grader takes the FIRST
|
||||||
|
# `.decide(` in main.py and reads a NAME rather than a value, so a second call
|
||||||
|
# site - or a decoy - would defeat it; this card deliberately adds neither, and
|
||||||
|
# extends the existing grader instead of writing a second one.
|
||||||
|
check("...and `soc` is handed over as the MEASURED reading, bare. Not "
|
||||||
|
"`self.soc or 100`, not a `float(..., 100)`, not the maintenance floor: "
|
||||||
|
"a full battery is exactly the value that disarms SAFETY-24's floor, and "
|
||||||
|
"NOTES.md 4.4b records this project shipping that exact defaulting once",
|
||||||
|
_soc_src == "self.soc")
|
||||||
|
check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
|
||||||
|
f"{len(_peak_calls)}), so the call graded above is unambiguously the "
|
||||||
|
f"shipped one. Other modules may call their own decide() - OPT-04's "
|
||||||
|
f"arbitrage.decide() does - and that must not silently move which call "
|
||||||
|
f"this grader reads",
|
||||||
|
len(_peak_calls) == 1)
|
||||||
|
# ⚠️ AND WHICH KEY THE FLOOR COMES FROM, GRADED ON THE SOURCE. A mutation that
|
||||||
|
# swapped `peak_soc_floor` for `maintenance_soc_floor` SURVIVED every
|
||||||
|
# behavioural check in this file, because the two defaults are close enough that
|
||||||
|
# no fixture separated them - which is exactly the coupling the card forbids and
|
||||||
|
# exactly the kind of survivor a value-based check cannot see. This grades the
|
||||||
|
# NAME, and part 9 below now also separates them behaviourally.
|
||||||
|
_floor_src = ast.unparse(next(k.value for k in _call.keywords
|
||||||
|
if k.arg == "soc_floor")).replace("'", '"')
|
||||||
|
print(f" main.py's decide(soc_floor=...) is `{_floor_src}`")
|
||||||
|
check("...and the floor comes from `peak_soc_floor`, defaulted from the ONE "
|
||||||
|
"mirror - not from `maintenance_soc_floor` (a drain target, not a spend "
|
||||||
|
"limit) and not from a literal, which would be CFG-03's fourth site",
|
||||||
|
'"peak_soc_floor"' in _floor_src
|
||||||
|
and "maintenance_soc_floor" not in _floor_src
|
||||||
|
and 'PEAK_OPTION_DEFAULTS["peak_soc_floor"]' in _floor_src)
|
||||||
|
|
||||||
# SAFETY-24 must be pointed at from the module that needs it, not only from a
|
# SAFETY-24 must be pointed at from the module that needs it, not only from a
|
||||||
# review thread - a ticket nobody can find from the code is a ticket nobody
|
# review thread - a ticket nobody can find from the code is a ticket nobody
|
||||||
@@ -1147,10 +1267,20 @@ check("peakguard.py names SAFETY-24, the missing SoC backstop on the forced "
|
|||||||
# CFG-03: no fourth defaults site.
|
# CFG-03: no fourth defaults site.
|
||||||
cfg = (HERE / "config.yaml").read_text(encoding="utf-8")
|
cfg = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||||||
val = (HERE / "app" / "validate.py").read_text(encoding="utf-8")
|
val = (HERE / "app" / "validate.py").read_text(encoding="utf-8")
|
||||||
check("no new option was added: config.yaml names no peak-guard key, and "
|
# ⚠️ REWRITTEN BY SAFETY-24, BECAUSE IT IS NO LONGER TRUE AS WRITTEN. SAFETY-07
|
||||||
"§14's kW/W trap `capacity_peak_limit_kw` stays declined",
|
# added no option and this check said so. This card adds TWO and says which -
|
||||||
"peak_guard" not in cfg and "capacity_peak_limit_kw" not in cfg
|
# leaving the old sentence in place while the file it reads has grown two keys
|
||||||
and "peak_shave" not in cfg)
|
# would be a green line asserting the opposite of the diff. What is still
|
||||||
|
# refused is a THIRD: §14's kW/W trap stays declined, and no key beyond the two
|
||||||
|
# named ones exists.
|
||||||
|
_peak_keys = sorted(k for k in ("peak_guard", "peak_shave",
|
||||||
|
"capacity_peak_limit_kw")
|
||||||
|
if k in cfg)
|
||||||
|
check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
|
||||||
|
"trap `capacity_peak_limit_kw` stays declined, and no speculative "
|
||||||
|
"`peak_guard`/`peak_shave` block appeared beside it",
|
||||||
|
_peak_keys == []
|
||||||
|
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_soc_floor"])
|
||||||
# Graded on the parsed call syntax, not on the text: the module docstring names
|
# Graded on the parsed call syntax, not on the text: the module docstring names
|
||||||
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
||||||
# satisfied by deleting the explanation and would fail on writing one.
|
# satisfied by deleting the explanation and would fail on writing one.
|
||||||
@@ -1161,11 +1291,14 @@ _getters = [n for n in ast.walk(ast.parse(src))
|
|||||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||||
and n.func.attr == "get"]
|
and n.func.attr == "get"]
|
||||||
print(f" peakguard.py makes {len(_getters)} options-style `.get()` calls")
|
print(f" peakguard.py makes {len(_getters)} options-style `.get()` calls")
|
||||||
check("peakguard.py reads no option of its own: it makes no `.get()` call at "
|
check("peakguard.py still READS no option: it makes no `.get()` call at all "
|
||||||
"all and takes `cap_w` as an argument. CFG-03's three defaults sites are "
|
"and takes `cap_w`, `soc` and `soc_floor` as arguments. CFG-03's three "
|
||||||
"config.yaml's `options:`, config.yaml's `schema:` and main.py's literal "
|
"defaults sites are config.yaml's `options:`, config.yaml's `schema:` "
|
||||||
"fallbacks - this card adds no fourth",
|
"and main.py's fallbacks; PEAK_OPTION_DEFAULTS is a MIRROR of the first, "
|
||||||
not _getters and "cap_w" in _DECIDE_ARGS)
|
"TARIFF-02-style, not a fourth site - it is what main.py and validate.py "
|
||||||
|
"read INSTEAD of typing literals",
|
||||||
|
not _getters and "cap_w" in _DECIDE_ARGS and "soc" in _DECIDE_ARGS
|
||||||
|
and "soc_floor" in _DECIDE_ARGS)
|
||||||
# And the fallback in main.py must equal the shipped default, for the reason
|
# And the fallback in main.py must equal the shipped default, for the reason
|
||||||
# test_validate.py's CFG-03 block spells out at length: the two diverging is a
|
# test_validate.py's CFG-03 block spells out at length: the two diverging is a
|
||||||
# live defect in this repo for `maintenance_charge_w`, and it is not to be
|
# live defect in this repo for `maintenance_charge_w`, and it is not to be
|
||||||
@@ -1182,7 +1315,494 @@ check("...and every literal fallback for peak_cap_w in main.py equals the "
|
|||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
print("8. mutation: what defeats this guard, and which check bites")
|
print("8. SAFETY-24: the energy floor under the forced discharge")
|
||||||
|
|
||||||
|
# ⚠️ THE STIMULUS FIRST, AND IT IS NOT DEGENERATE. Everything below is graded on
|
||||||
|
# a quarter that GENUINELY WANTS A FORCED DISCHARGE - `wanted > 0` - because the
|
||||||
|
# state of charge governs nothing on a cycle where the guard was never going to
|
||||||
|
# discharge. A check placed on a quiet quarter would pass with the gate removed,
|
||||||
|
# with the gate inverted, and with the gate reading the wrong sensor.
|
||||||
|
a_hungry = averager_at(CEIL + 900.0, 600.0)
|
||||||
|
p_hungry = PeakTracker.project(a_hungry, B + timedelta(seconds=600))
|
||||||
|
WANTED = BATT_NOW + excess_w(p_hungry, CEIL)
|
||||||
|
print(f" the stimulus: a quarter projecting {p_hungry.projected_w:.0f} W "
|
||||||
|
f"against a {CEIL:.0f} W ceiling with "
|
||||||
|
f"{QUARTER_S - p_hungry.elapsed_s:.0f} s left; a forced discharge of "
|
||||||
|
f"{WANTED:+.0f} W is what would hold it")
|
||||||
|
check("the stimulus really does want a forced discharge, so the SoC is a "
|
||||||
|
"quantity that decides something here", WANTED > 0.0)
|
||||||
|
|
||||||
|
# --- the predicate, which is the ONE place the question is answered ----------
|
||||||
|
PERMITS = discharge_permitted(SOC_OK, SOC_FLOOR)
|
||||||
|
AT = discharge_permitted(SOC_FLOOR, SOC_FLOOR)
|
||||||
|
UNDER = discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR)
|
||||||
|
UNREAD = discharge_permitted(None, SOC_FLOOR)
|
||||||
|
NOFLOOR = discharge_permitted(SOC_OK, None)
|
||||||
|
print(f" discharge_permitted: {SOC_OK:.0f} % -> {PERMITS!r}")
|
||||||
|
print(f" {SOC_FLOOR:.0f} % (exactly at) -> {AT!r}")
|
||||||
|
print(f" None (unreadable) -> {UNREAD!r}")
|
||||||
|
check("a pack above the floor may be discharged", PERMITS is None)
|
||||||
|
check("...a pack AT the floor may not - `<=`, not `<`. Permitting one more "
|
||||||
|
"cycle at the floor would make the floor mean 'one cycle below the "
|
||||||
|
"floor', which is the whole quantity this option names",
|
||||||
|
AT == FLOOR_AT_SOC_FLOOR and UNDER == FLOOR_AT_SOC_FLOOR)
|
||||||
|
check("...an UNREADABLE SoC is refused, and refused with its OWN reason. A "
|
||||||
|
"failed read is not an observation, and a full battery is exactly the "
|
||||||
|
"value that would disarm this floor",
|
||||||
|
UNREAD == FLOOR_SOC_UNREADABLE and UNREAD != AT)
|
||||||
|
check("...and no configured floor is not permission either", NOFLOOR is not None)
|
||||||
|
# THE DISARMING ALTERNATIVE, COMPUTED RATHER THAN DESCRIBED. `float(soc or 100)`
|
||||||
|
# is the shape NOTES.md 4.4b records the stage-5 loop actually shipping.
|
||||||
|
check("...and the defaulting form that was ACTUALLY shipped once in this "
|
||||||
|
"project - reading a missing SoC as a full battery - would have permitted "
|
||||||
|
"it, so this is a live direction and not a hypothetical",
|
||||||
|
discharge_permitted(float(None or 100), SOC_FLOOR) is None)
|
||||||
|
|
||||||
|
# --- the decision -----------------------------------------------------------
|
||||||
|
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK,
|
||||||
|
soc_floor=SOC_FLOOR)
|
||||||
|
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
|
soc=SOC_FLOOR, soc_floor=SOC_FLOOR)
|
||||||
|
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None,
|
||||||
|
soc_floor=SOC_FLOOR)
|
||||||
|
print(f" with battery: {d_ok.explain()}")
|
||||||
|
print(f" at the floor: {d_lim.explain()}")
|
||||||
|
check("with battery to spare the guard sizes the forced discharge exactly as "
|
||||||
|
"SAFETY-07 always did - this card changes nothing above the floor",
|
||||||
|
d_ok.engaged and not d_ok.floor_limited and near(d_ok.floor_w, WANTED, 1e-6))
|
||||||
|
check("at the floor it STOPS, and the floor it files is 0 W - not the discharge "
|
||||||
|
"it wanted, and not a negative number",
|
||||||
|
d_lim.engaged and d_lim.floor_limited and near(d_lim.floor_w, 0.0))
|
||||||
|
check("...and it REPORTS being floor-limited rather than silently under-"
|
||||||
|
"shaving: the decision carries the flag, the reason it bound, the SoC it "
|
||||||
|
"read and the floor it read",
|
||||||
|
d_lim.floor_limited and d_lim.soc_floor_reason == FLOOR_AT_SOC_FLOOR
|
||||||
|
and near(d_lim.soc, SOC_FLOOR) and near(d_lim.soc_floor, SOC_FLOOR))
|
||||||
|
check("...and it still says what it WOULD have taken, so the under-shave is a "
|
||||||
|
"number rather than a silence",
|
||||||
|
near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w)
|
||||||
|
check("...and its one-line explanation tells the operator the consequence in "
|
||||||
|
"the words that matter - the quarter is going over the ceiling",
|
||||||
|
"FLOOR-LIMITED" in d_lim.explain()
|
||||||
|
and "WILL GO OVER THE CEILING" in d_lim.explain()
|
||||||
|
and f"{CEIL:.0f} W" in d_lim.explain())
|
||||||
|
check("an unreadable SoC lands in the same branch, with the OTHER reason - so "
|
||||||
|
"an operator reading one line can tell a flat battery from a dead sensor",
|
||||||
|
d_unread.floor_limited and near(d_unread.floor_w, 0.0)
|
||||||
|
and d_unread.soc_floor_reason == FLOOR_SOC_UNREADABLE
|
||||||
|
and d_unread.soc is None)
|
||||||
|
check("floor-limited and saturated are MUTUALLY EXCLUSIVE, by construction: the "
|
||||||
|
"branch returns before the achievable_w clamp that sets `saturated`, so "
|
||||||
|
"the two notifications can never both fire on one cycle",
|
||||||
|
not d_lim.saturated and not d_unread.saturated)
|
||||||
|
|
||||||
|
# --- and NOT graded where it is degenerate ----------------------------------
|
||||||
|
# The same empty pack on a quarter that is UNDER the ceiling. A gate placed one
|
||||||
|
# line earlier would report FLOOR-LIMITED here - on a quiet afternoon, over a
|
||||||
|
# guard that was never going to discharge - and an operator who is told the peak
|
||||||
|
# will be exceeded on a quarter that is nowhere near it stops reading the
|
||||||
|
# notification that matters.
|
||||||
|
d_quiet = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||||
|
B + timedelta(seconds=450)),
|
||||||
|
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
|
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR)
|
||||||
|
print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}")
|
||||||
|
check("an empty pack on a quarter that does not need shaving is IDLE, not "
|
||||||
|
"floor-limited. The SoC is only graded where a forced discharge is "
|
||||||
|
"actually being asked for - grading it earlier would raise the alarm on "
|
||||||
|
"every quiet cycle of the day",
|
||||||
|
not d_quiet.engaged and not d_quiet.floor_limited
|
||||||
|
and d_quiet.floor_w is None)
|
||||||
|
# And the BLIND branch is untouched: it never forces a discharge, so there is
|
||||||
|
# nothing for an energy floor to bound there.
|
||||||
|
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
|
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True)
|
||||||
|
check("...and an empty pack does not disturb the BLIND branch either: SAFETY-"
|
||||||
|
"18's cut is still a 0 W floor, still blocks charging, and is still not "
|
||||||
|
"reported as an energy problem",
|
||||||
|
d_blind_low.engaged and near(d_blind_low.floor_w, 0.0)
|
||||||
|
and d_blind_low.blind == BLIND_FROZEN and not d_blind_low.floor_limited)
|
||||||
|
|
||||||
|
# --- the claim, through the real arbiter ------------------------------------
|
||||||
|
lc = peakguard.claim(d_lim)
|
||||||
|
print(f" floor-limited claim: {lc.source} {lc.kind} lo={lc.lo:.0f} "
|
||||||
|
f"hi={lc.hi} reason={lc.reason!r}")
|
||||||
|
r_charge = resolve(SAFE + [Claim.set("loop", P_LOOP, -1500.0, "grid"), lc])
|
||||||
|
r_disch = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), lc])
|
||||||
|
r_stop = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), lc])
|
||||||
|
print(f" loop wants to charge 1500 W: {r_charge.explain()}")
|
||||||
|
print(f" loop wants to discharge 1800 W: {r_disch.explain()}")
|
||||||
|
check("a floor-limited guard STILL blocks charging - 0 W is one-directional, "
|
||||||
|
"it can only ever lower delivered offtake, and adding import in the "
|
||||||
|
"quarter that sets the bill is the one thing it must not permit",
|
||||||
|
near(r_charge.target_w, 0.0) and not r_charge.contradiction)
|
||||||
|
check("...and it does NOT pull the loop off a discharge it is already doing. A "
|
||||||
|
"`lo` limit is structurally incapable of capping discharge, which is why "
|
||||||
|
"the action here is 0 W and never a negative floor",
|
||||||
|
near(r_disch.target_w, 1800.0, 1e-9))
|
||||||
|
check("...and every P_SAFETY stop still outranks it. This is an ENERGY bound, "
|
||||||
|
"not a trip: 11.2's table is unchanged by this card",
|
||||||
|
near(r_stop.target_w, 0.0) and not r_stop.contradiction)
|
||||||
|
check("...and its arbiter reason names the SoC floor rather than reusing "
|
||||||
|
"'blind' or 'quarter peak', so the one line an operator reads tells the "
|
||||||
|
"three 0 W states apart", lc.reason == "SoC floor"
|
||||||
|
and peakguard.claim(d_ok).reason == "quarter peak")
|
||||||
|
|
||||||
|
# --- THE PROPERTY THE WHOLE SHAPE RESTS ON: no negative floor, ever ----------
|
||||||
|
# A `lo` limit is incapable of capping discharge or export only while it is
|
||||||
|
# non-negative. If this card could produce `lo < 0` it would have broken 9.1 as
|
||||||
|
# a property of the object, and the guard would be capping the very thing it is
|
||||||
|
# forbidden to cap. Swept rather than argued.
|
||||||
|
SWEEP_NEG = SWEEP_BOTH = SWEEP_N = 0
|
||||||
|
SWEEP_HI = True
|
||||||
|
for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
|
||||||
|
LIVE_MAX_W * 3):
|
||||||
|
for elapsed in (30.0, 450.0, 870.0):
|
||||||
|
_pr = PeakTracker.project(averager_at(load, elapsed),
|
||||||
|
B + timedelta(seconds=elapsed))
|
||||||
|
for bw in (-2000.0, 0.0, 400.0, 4000.0):
|
||||||
|
for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||||||
|
for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0):
|
||||||
|
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W,
|
||||||
|
max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl)
|
||||||
|
SWEEP_N += 1
|
||||||
|
if _d.floor_w is not None and _d.floor_w < 0.0:
|
||||||
|
SWEEP_NEG += 1
|
||||||
|
if _d.floor_limited and _d.saturated:
|
||||||
|
SWEEP_BOTH += 1
|
||||||
|
_c = peakguard.claim(_d)
|
||||||
|
if _c is not None and not (isinf(_c.hi) and _c.hi > 0):
|
||||||
|
SWEEP_HI = False
|
||||||
|
print(f" swept {SWEEP_N} (load x elapsed x battery x SoC x floor) "
|
||||||
|
f"combinations, SoC included: {SWEEP_NEG} negative floors, "
|
||||||
|
f"{SWEEP_BOTH} decisions both floor-limited and saturated")
|
||||||
|
check(f"NOT ONE of the {SWEEP_N} combinations produces a negative floor. That "
|
||||||
|
f"is 9.1's 'discharge and export are never capped by the peak guard' as a "
|
||||||
|
f"property of the object, and SAFETY-24 preserves it - the only two "
|
||||||
|
f"values floor_w takes are 0.0 and a positive min(wanted, achievable)",
|
||||||
|
SWEEP_NEG == 0 and SWEEP_N > 600)
|
||||||
|
check("...and `hi` stays +inf on every claim any of them files",
|
||||||
|
SWEEP_HI)
|
||||||
|
check("...and no combination is both floor-limited and saturated, so the two "
|
||||||
|
"notifications are exclusive on measured inputs and not merely by reading "
|
||||||
|
"the code", SWEEP_BOTH == 0)
|
||||||
|
|
||||||
|
# --- TEST-14: a required field must not quietly gain a default --------------
|
||||||
|
# ⚠️ PORTED AS A SCAN, NOT HAND-WRITTEN PER FIELD. TEST-14 is open because an
|
||||||
|
# outcome type's required field gained a default in three separate files and no
|
||||||
|
# check noticed. The pinned sets below go red if ANY field or argument moves
|
||||||
|
# between required and defaulted - including a future one nobody has thought of
|
||||||
|
# - which is the whole difference between a scan and a case.
|
||||||
|
_sig = inspect.signature(decide)
|
||||||
|
_required_kw = {n for n, prm in _sig.parameters.items()
|
||||||
|
if prm.kind is prm.KEYWORD_ONLY and prm.default is prm.empty}
|
||||||
|
print(f" decide() required keyword-only arguments: {sorted(_required_kw)}")
|
||||||
|
check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() "
|
||||||
|
"with no default. A default on either is TEST-14's exact shape and would "
|
||||||
|
"be the quiet fallback this card exists to remove - and a default on "
|
||||||
|
"`soc_floor` would additionally be CFG-03's fourth defaults site",
|
||||||
|
_required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc",
|
||||||
|
"soc_floor"})
|
||||||
|
_req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision)
|
||||||
|
if f.default is dataclasses.MISSING
|
||||||
|
and f.default_factory is dataclasses.MISSING)
|
||||||
|
print(f" PeakDecision fields with no default: {list(_req_fields)}")
|
||||||
|
check("...and PeakDecision's required fields are unchanged by this card: the "
|
||||||
|
"four SAFETY-07 pinned, and the SAFETY-24 additions are genuinely "
|
||||||
|
"optional reporting rather than required fields handed a default",
|
||||||
|
_req_fields == ("engaged", "floor_w", "ceiling_w", "ceiling_from"))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("9. the exhausting replay (AC 4), and the startup refusal")
|
||||||
|
|
||||||
|
# ⚠️ THE PACK IS SIZED FROM THE MEASUREMENT, NOT PICKED. `WH_07` is what the
|
||||||
|
# guard actually asks for on 2026-08-20 - re-derived from the committed capture
|
||||||
|
# on every run, never quoted - and the modelled pack is then chosen so the energy
|
||||||
|
# between the start and the EMS floor is ONE THIRD of it. That makes "a quarter
|
||||||
|
# that would exhaust the pack" true by construction of the measurement rather
|
||||||
|
# than by a number typed into this file: the day wants three times what the pack
|
||||||
|
# has to give above the floor, so it must run out, and without an EMS floor it
|
||||||
|
# must run through the BMS one too.
|
||||||
|
BAD = [pth for pth in REPLAY if "2026-08-20" in pth.name][0]
|
||||||
|
WH_BAD = replay(BAD, guarded=True)["wh"]
|
||||||
|
START_SOC = SOC_FLOOR + (SOC_FLOOR - BMS_FLOOR)
|
||||||
|
USABLE_WH = 100.0 * (WH_BAD / 3.0) / (START_SOC - SOC_FLOOR)
|
||||||
|
PACK = (USABLE_WH, START_SOC, BMS_FLOOR)
|
||||||
|
# How far past the floor one cycle at the rail can carry the pack before the
|
||||||
|
# next decision sees it. DERIVED from the cycle and the modelled pack, because a
|
||||||
|
# tolerance typed as a constant is a tolerance that stops meaning anything.
|
||||||
|
OVERSHOOT_PP = 100.0 * (LIVE_MAX_W * CYCLE_S / 3600.0) / USABLE_WH
|
||||||
|
print(f" the guard's measured appetite on {BAD.name}: {WH_BAD:.0f} Wh")
|
||||||
|
print(f" modelled pack: {USABLE_WH:.0f} Wh usable, starting at "
|
||||||
|
f"{START_SOC:.0f} %, BMS cut at {BMS_FLOOR:.0f} %, EMS floor at "
|
||||||
|
f"{SOC_FLOOR:.0f} %")
|
||||||
|
print(f" -> {(START_SOC - SOC_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above the "
|
||||||
|
f"EMS floor, {(START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above "
|
||||||
|
f"the BMS one - against {WH_BAD:.0f} Wh of appetite; one cycle at the "
|
||||||
|
f"rail is {OVERSHOOT_PP:.2f} pp of this pack")
|
||||||
|
check("the fixture is a pack this day genuinely exhausts, and that is derived "
|
||||||
|
"from the day's own measured appetite rather than asserted: the energy "
|
||||||
|
"above the BMS cut-out is less than the guard wants",
|
||||||
|
(START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH < WH_BAD)
|
||||||
|
|
||||||
|
# TWO WORLDS, SAME PACK, SAME CAPTURE.
|
||||||
|
# BEFORE: the EMS floor set to the BMS cut-out - i.e. the BMS is the sole
|
||||||
|
# energy backstop, which is precisely what SAFETY-07 shipped.
|
||||||
|
# AFTER: the EMS floor as config.yaml ships it.
|
||||||
|
BEFORE = replay(BAD, guarded=True, pack=PACK, soc_floor=BMS_FLOOR)
|
||||||
|
AFTER = replay(BAD, guarded=True, pack=PACK, soc_floor=SOC_FLOOR)
|
||||||
|
print(f" BEFORE (BMS is the only backstop): ends at {BEFORE['end_soc']:.1f} %"
|
||||||
|
f", low-water {BEFORE['min_soc']:.1f} %, {BEFORE['wh']:.0f} Wh out, "
|
||||||
|
f"{BEFORE['at_bms']} guard cycles spent AT the BMS limit, "
|
||||||
|
f"{BEFORE['limited']} floor-limited")
|
||||||
|
print(f" AFTER (peak_soc_floor {SOC_FLOOR:.0f} %): ends at "
|
||||||
|
f"{AFTER['end_soc']:.1f} %, low-water {AFTER['min_soc']:.1f} %, "
|
||||||
|
f"{AFTER['wh']:.0f} Wh out, {AFTER['at_bms']} cycles at the BMS limit, "
|
||||||
|
f"{AFTER['limited']} floor-limited")
|
||||||
|
check("BEFORE: the day really does take the pack to the BMS cut-out and hold it "
|
||||||
|
"there. That is the finding this card answers - not a pack destroyed, a "
|
||||||
|
"pack repeatedly parked at its own protection with no EMS-side record of "
|
||||||
|
"why", BEFORE["min_soc"] <= BMS_FLOOR + 1e-9 and BEFORE["at_bms"] > 0)
|
||||||
|
check("AFTER: the forced discharge STOPS AT THE CONFIGURED FLOOR - to within "
|
||||||
|
"the one cycle of overshoot the 15 s tick allows, derived above and not "
|
||||||
|
"typed - and the pack never reaches the BMS limit at all. That is AC 1, "
|
||||||
|
"replayed rather than reasoned about",
|
||||||
|
AFTER["min_soc"] >= SOC_FLOOR - OVERSHOOT_PP and AFTER["at_bms"] == 0
|
||||||
|
and AFTER["min_soc"] > BMS_FLOOR + 1.0)
|
||||||
|
check("...and it does not merely stop, it SAYS SO: floor-limited cycles are "
|
||||||
|
"counted off the decisions themselves, so the under-shave is reported "
|
||||||
|
"rather than silent", AFTER["limited"] > 0)
|
||||||
|
# ⚠️ WHAT "BEFORE" IS AND IS NOT. It is the shipped code with the EMS floor set
|
||||||
|
# equal to the BMS cut-out, which reproduces the pre-card ENERGY trajectory
|
||||||
|
# exactly - the pack drains to the BMS and the BMS is the only thing that stops
|
||||||
|
# it. What it does not reproduce is the silence: pre-card there was no gate to
|
||||||
|
# report at all, whereas this run reports one at the BMS limit, by which point
|
||||||
|
# the pack is already there. The trajectory is the thing being compared.
|
||||||
|
check("...and the two worlds differ in the trajectory, not merely in a flag: "
|
||||||
|
"before, every floor-limited cycle happens with the pack ALREADY at the "
|
||||||
|
"BMS limit; after, none of them do",
|
||||||
|
BEFORE["at_bms"] > 0 and AFTER["at_bms"] == 0)
|
||||||
|
# THE DERIVED VERDICT: the energy the card withholds is exactly the energy
|
||||||
|
# between the two floors. Not "less", not "a bit" - the difference in Wh equals
|
||||||
|
# the difference in percentage points times the pack, to within one cycle.
|
||||||
|
GAP_WH = (SOC_FLOOR - BMS_FLOOR) / 100.0 * USABLE_WH
|
||||||
|
TOL = 2.0 * LIVE_MAX_W * CYCLE_S / 3600.0 # one cycle of overshoot per run
|
||||||
|
print(f" energy withheld: {BEFORE['wh'] - AFTER['wh']:.1f} Wh, against "
|
||||||
|
f"{GAP_WH:.1f} Wh between the two floors (tolerance {TOL:.1f} Wh = one "
|
||||||
|
f"cycle of overshoot at the rail in each of the two runs)")
|
||||||
|
check("the energy this card withholds IS the energy between the two floors, to "
|
||||||
|
"within one cycle at the rail - so the floor is doing the arithmetic it "
|
||||||
|
"claims and is not merely correlated with doing less",
|
||||||
|
abs((BEFORE["wh"] - AFTER["wh"]) - GAP_WH) <= TOL)
|
||||||
|
check("...and no cycle in either world files a negative floor, on real "
|
||||||
|
"captured data and not only on the synthetic sweep",
|
||||||
|
BEFORE["neg_floor"] == 0 and AFTER["neg_floor"] == 0)
|
||||||
|
# THE HONEST HALF. Stopping costs money, and the check says how much rather than
|
||||||
|
# looking away from it.
|
||||||
|
_ub = max(BEFORE["blocks"].values())
|
||||||
|
_ab = max(AFTER["blocks"].values())
|
||||||
|
print(f" the price of the floor: worst quarter {_ub:.0f} W before, "
|
||||||
|
f"{_ab:.0f} W after - a floor-limited guard shaves LESS, on purpose, and "
|
||||||
|
f"the operator is told so")
|
||||||
|
check("stopping at the floor costs peak shaving, and this file says so out "
|
||||||
|
"loud rather than reporting only the SoC it saved: the worst quarter is "
|
||||||
|
"no better with the floor in place", _ab >= _ub - 1e-6)
|
||||||
|
|
||||||
|
# --- the wiring: one real cycle on an empty pack -----------------------------
|
||||||
|
# ⚠️ `maintenance_soc_floor` IS SET, AND SET TO A VALUE THAT WOULD FLIP THE
|
||||||
|
# ANSWER. The pack sits one point under `peak_soc_floor` and well ABOVE the
|
||||||
|
# maintenance floor, so a controller that read the maintenance key would come
|
||||||
|
# out NOT limited here. Without this the two keys are indistinguishable on every
|
||||||
|
# fixture in this file - a mutation swapping them survived until it was added.
|
||||||
|
MAINT_LOW = 5
|
||||||
|
c24, hass24, NET24, _ = wired_over_limit(
|
||||||
|
TMP / "soc24.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0,
|
||||||
|
opts={"maintenance_soc_floor": MAINT_LOW})
|
||||||
|
check(f"the fixture separates the two floors: the pack at {SOC_FLOOR - 1:.0f} % "
|
||||||
|
f"is under peak_soc_floor {SOC_FLOOR:.0f} % and over "
|
||||||
|
f"maintenance_soc_floor {MAINT_LOW} %, so reading the wrong key gives the "
|
||||||
|
f"wrong answer here", MAINT_LOW < SOC_FLOOR - 1.0 < SOC_FLOOR)
|
||||||
|
print(f" a real cycle over a {NET24:.0f} W quarter with the pack at "
|
||||||
|
f"{SOC_FLOOR - 1.0:.0f} %: {c24.peak_guard.explain()}")
|
||||||
|
check("the REAL Controller reaches the floor-limited branch, so main.py hands "
|
||||||
|
"decide() a state of charge at all", c24.peak_guard.floor_limited)
|
||||||
|
check("...and the number written to the inverter is not a forced discharge - "
|
||||||
|
"the pack is not spent for money below the floor",
|
||||||
|
hass24.writes and hass24.writes[-1] >= -1e-6
|
||||||
|
and hass24.writes[-1] <= c24.peak_guard.wanted_w)
|
||||||
|
check("...and the operator is TOLD THE PEAK WILL BE EXCEEDED, exactly once, "
|
||||||
|
"with its own notification id - AC 2, and the reason failing to shave "
|
||||||
|
"must not be silent",
|
||||||
|
hass24.notifications.count("goodwe_peak_soc_floor") == 1
|
||||||
|
and "goodwe_peak_saturated" not in hass24.notifications)
|
||||||
|
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
|
||||||
|
check("...once per episode, not once per cycle - a quarter of an hour of "
|
||||||
|
"identical cards is how an operator learns to dismiss the one that "
|
||||||
|
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
|
||||||
|
rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
|
||||||
|
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
|
||||||
|
check("the status page carries exactly one peak-guard row and it is NOT ok - "
|
||||||
|
"the money is going to be spent and a human is the only actuator left",
|
||||||
|
len(rows24) == 1 and not rows24[0]["ok"]
|
||||||
|
and "FLOOR-LIMITED" in rows24[0]["text"])
|
||||||
|
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
|
||||||
|
for e in c24.events))
|
||||||
|
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
|
||||||
|
# at 0 W in that state, so the guard's own answer is what is graded here.
|
||||||
|
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
|
soc=None, soc_floor=SOC_FLOOR)
|
||||||
|
check("decide() handed soc=None answers floor-limited rather than a full "
|
||||||
|
"battery - the whole 'a failed read is not an observation' rule, on the "
|
||||||
|
"one input where the disarming value is the plausible one",
|
||||||
|
d_nosoc.floor_limited and not d_nosoc.saturated)
|
||||||
|
# ⚠️ REVIEW-FOUND, AND THE CLAIM ABOVE USED TO BE WIDER THAN ITS CHECK. It read
|
||||||
|
# "a CONTROLLER whose SoC entity does not answer hands decide() None" while
|
||||||
|
# calling decide() directly and driving no Controller - so a reviewer could
|
||||||
|
# insert `self.soc = 100.0 if self.soc is None else self.soc` immediately above
|
||||||
|
# main.py's decide() call, which is the exact `float(soc or 100)` defaulting
|
||||||
|
# discharge_permitted()'s own docstring forbids, and every check here stayed
|
||||||
|
# green. The wiring property is STRONGER than the one that was claimed, and this
|
||||||
|
# is it: a Controller with no SoC never reaches the guard at all, because
|
||||||
|
# `inputs_ok` is False and cycle() returns at the safety stop first. Graded on
|
||||||
|
# the SERVICE CALL - decide() is spied - rather than on its arguments, so a
|
||||||
|
# defaulted value cannot satisfy it.
|
||||||
|
_nosoc_ctl, _nosoc_hass = built(TMP / "nosoc.db")
|
||||||
|
_nosoc_ctl.auto, _nosoc_ctl.soc, _nosoc_ctl.batt = True, None, 0.0
|
||||||
|
_nosoc_ctl.dev_min, _nosoc_ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||||
|
_nosoc_now = datetime.now(timezone.utc)
|
||||||
|
_nosoc_blk = datetime.fromtimestamp(
|
||||||
|
int(_nosoc_now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
|
||||||
|
for _i in range(0, int((_nosoc_now - _nosoc_blk).total_seconds()) + 1, 5):
|
||||||
|
_nosoc_ctl.p1.submit(sample(_nosoc_blk + timedelta(seconds=_i), CAP_W * 2))
|
||||||
|
_nosoc_ctl.grid = CAP_W * 2
|
||||||
|
_nosoc_seen = []
|
||||||
|
_nosoc_real = peakguard.decide
|
||||||
|
try:
|
||||||
|
peakguard.decide = lambda *a, **k: (_nosoc_seen.append(k.get("soc")),
|
||||||
|
_nosoc_real(*a, **k))[1]
|
||||||
|
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||||
|
_nosoc_ctl.cycle())
|
||||||
|
finally:
|
||||||
|
peakguard.decide = _nosoc_real
|
||||||
|
print(f" a Controller with no SoC: inputs_ok={_nosoc_ctl.inputs_ok}, "
|
||||||
|
f"decide() called {len(_nosoc_seen)} time(s), target "
|
||||||
|
f"{_nosoc_ctl.target:+.0f} W")
|
||||||
|
check("and a Controller whose SoC entity does not answer never reaches the "
|
||||||
|
"guard AT ALL - `inputs_ok` is False, cycle() stops at the safety claim, "
|
||||||
|
"and decide() is not called. A defaulting line above the call site "
|
||||||
|
"therefore cannot disarm this floor, which is a stronger guarantee than "
|
||||||
|
"handing decide() a None would be",
|
||||||
|
not _nosoc_ctl.inputs_ok and not _nosoc_seen
|
||||||
|
and abs(_nosoc_ctl.target) < 1e-9 and _nosoc_ctl.peak_guard is None)
|
||||||
|
|
||||||
|
# --- the startup refusal, in CFG-01 / MAINT-04's manner ----------------------
|
||||||
|
def opts24(**kw):
|
||||||
|
"""Otherwise-startable options, so the ONLY thing under test is rule 8."""
|
||||||
|
base = {"setpoint_entity": "number.sp", "meter_source": "off",
|
||||||
|
"meter_entity": "sensor.grid", "max_w": 2000,
|
||||||
|
"meter_phases": 1, "meter_max_age_s": 30, "sign_disagree_w": 300,
|
||||||
|
"maintenance_charge_w": 2000,
|
||||||
|
"peak_soc_floor": PEAK_OPTION_DEFAULTS["peak_soc_floor"],
|
||||||
|
"bms_soc_floor": PEAK_OPTION_DEFAULTS["bms_soc_floor"]}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
BASE_OK = validate_startup_config(opts24())
|
||||||
|
check("the SHIPPED pair starts - a validator that refuses config.yaml's own "
|
||||||
|
"defaults is a validator nobody keeps", BASE_OK == [])
|
||||||
|
|
||||||
|
|
||||||
|
def rule8(problems):
|
||||||
|
return [x for x in problems if "bms_soc_floor" in x]
|
||||||
|
|
||||||
|
|
||||||
|
below = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR - 5)))
|
||||||
|
equal = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR)))
|
||||||
|
above = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR + 1)))
|
||||||
|
junk = validate_startup_config(opts24(peak_soc_floor="soon"))
|
||||||
|
print(f" refusal at {BMS_FLOOR - 5:.0f} %: {below[0][:96] if below else '(none)'}...")
|
||||||
|
check("a floor BELOW the pack's own cut-out is refused at startup, in CFG-01 / "
|
||||||
|
"MAINT-04's manner - the operator states a bound, the BMS enforces a "
|
||||||
|
"different one, and nothing would say which won", len(below) == 1)
|
||||||
|
check("...and so is a floor EXACTLY AT it, which is the interesting case: equal "
|
||||||
|
"buys zero margin, so it can never be the thing that stops the discharge "
|
||||||
|
"and the status page would report a backstop that cannot bind. Rule 6 "
|
||||||
|
"permits equality because equality there leaves one working point; here "
|
||||||
|
"it leaves none", len(equal) == 1)
|
||||||
|
check("...while one point above is accepted, so this refuses the disarmed case "
|
||||||
|
"only and not 'close to the limit'", above == [])
|
||||||
|
check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the "
|
||||||
|
"operator can act on it without reading the source",
|
||||||
|
f"{BMS_FLOOR:.0f} %" in below[0]
|
||||||
|
and f"{BMS_FLOOR - 5:.0f} %" in below[0]
|
||||||
|
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0])
|
||||||
|
check("...and a non-numeric floor is refused rather than defaulted, for the "
|
||||||
|
"reason every read in this file is: a value that cannot be parsed is not "
|
||||||
|
"an observation of anything",
|
||||||
|
any("must both be numbers" in x for x in junk))
|
||||||
|
# ⚠️ AND IT IS NOT SCOPED TO ANYTHING. Rule 6's comment gives the argument: a
|
||||||
|
# contradictory pair is contradictory whether or not the feature is switched on,
|
||||||
|
# and scoping it leaves the trap armed for the one operator action that cannot
|
||||||
|
# be expected to change what a floor means.
|
||||||
|
check("...and the rule is not scoped to meter_source or to any enable flag - "
|
||||||
|
"the pair is wrong on a P1 install too, and 'turn P1 on' is not an action "
|
||||||
|
"that should change what a SoC floor means",
|
||||||
|
len(rule8(validate_startup_config(opts24(
|
||||||
|
peak_soc_floor=BMS_FLOOR - 5, meter_source="ha_signed",
|
||||||
|
p1_net_entity="sensor.net")))) == 1)
|
||||||
|
|
||||||
|
# --- CFG-03: the mirror, pinned to config.yaml -------------------------------
|
||||||
|
# TARIFF-02's pattern exactly - and pinned by READING the shipped file, not by
|
||||||
|
# transcribing it, which is the only version of this check that can catch drift.
|
||||||
|
_found24 = {k: read_option(k) for k in PEAK_OPTION_DEFAULTS}
|
||||||
|
print(f" config.yaml ships {_found24}, peakguard mirrors "
|
||||||
|
f"{dict(PEAK_OPTION_DEFAULTS)}")
|
||||||
|
check("every SAFETY-24 option is shipped in config.yaml's options: block",
|
||||||
|
all(v is not None for v in _found24.values()))
|
||||||
|
check("...and each shipped default equals PEAK_OPTION_DEFAULTS, the ONE in-code "
|
||||||
|
"mirror - TARIFF-02's move, so this card adds no fourth defaults site",
|
||||||
|
all(float(_found24[k]) == float(v)
|
||||||
|
for k, v in PEAK_OPTION_DEFAULTS.items()))
|
||||||
|
_schema24 = [k for k in PEAK_OPTION_DEFAULTS if read_schema_max(k) is not None]
|
||||||
|
check("...and both are in the schema: block too, or the Supervisor drops the "
|
||||||
|
"option and the add-on silently runs on the mirror instead of the "
|
||||||
|
"operator's value", sorted(_schema24) == sorted(PEAK_OPTION_DEFAULTS))
|
||||||
|
_val_src = (HERE / "app" / "validate.py").read_text(encoding="utf-8")
|
||||||
|
check("...and validate.py takes its fallbacks from that same mirror rather than "
|
||||||
|
"typing literals - which is the divergence its own rule 6 documents "
|
||||||
|
"(maintenance_charge_w 2000 there, 2500 in main.py), refused a second "
|
||||||
|
"outing here",
|
||||||
|
"PEAK_OPTION_DEFAULTS" in _val_src
|
||||||
|
and not re.search(r'"peak_soc_floor"\s*,\s*[0-9]', _val_src)
|
||||||
|
and not re.search(r'"peak_soc_floor"\s*,\s*[0-9]', _mainsrc))
|
||||||
|
check("...and 20 % is not a number typed into config.yaml on a hunch: it is 10 "
|
||||||
|
"points of margin over the pack's own cut-out, which on the 13.8 kWh "
|
||||||
|
"reference pack is 1.57x the guard's worst measured day",
|
||||||
|
PEAK_OPTION_DEFAULTS["peak_soc_floor"]
|
||||||
|
- PEAK_OPTION_DEFAULTS["bms_soc_floor"] == 10
|
||||||
|
and 0.10 * 13800.0 / WH_BAD > 1.5)
|
||||||
|
check("...and it is a SEPARATE KEY from maintenance_soc_floor, whose SHIPPED "
|
||||||
|
f"VALUE ({MAINT_FLOOR:.0f} %) is not this one ({SOC_FLOOR:.0f} %) - which "
|
||||||
|
"is the argument for not sharing them, measured off config.yaml rather "
|
||||||
|
"than asserted: they are a drain TARGET and a spend LIMIT, and the site "
|
||||||
|
"that wants one does not thereby want the other",
|
||||||
|
MAINT_FLOOR != SOC_FLOOR
|
||||||
|
and "maintenance_soc_floor" not in PEAK_OPTION_DEFAULTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("10. mutation: what defeats these guards, and which check bites")
|
||||||
|
|
||||||
# Each entry re-implements ONE defeat and reports whether a check above would
|
# Each entry re-implements ONE defeat and reports whether a check above would
|
||||||
# have gone red. The last row is the honest one: it does NOT bite, and saying so
|
# have gone red. The last row is the honest one: it does NOT bite, and saying so
|
||||||
@@ -1199,7 +1819,7 @@ MUT.append(("blindness() returns None - the degenerate 0 W projection is graded"
|
|||||||
# 2. the frozen term dropped from the guard's inputs.
|
# 2. the frozen term dropped from the guard's inputs.
|
||||||
MUT.append(("frozen=False - SAFETY-18's term dropped",
|
MUT.append(("frozen=False - SAFETY-18's term dropped",
|
||||||
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||||
frozen=False).engaged and d_frozen.engaged,
|
frozen=False).engaged and d_frozen.engaged,
|
||||||
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
|
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
|
||||||
|
|
||||||
@@ -1247,12 +1867,68 @@ MUT.append(("NOT_OBSERVED silently replaced by a peak of 0.0 W",
|
|||||||
"NOTHING - and by construction: the ceiling is max(cap, peak), so a "
|
"NOTHING - and by construction: the ceiling is max(cap, peak), so a "
|
||||||
"zero peak is the TIGHTEST possible input to it"))
|
"zero peak is the TIGHTEST possible input to it"))
|
||||||
|
|
||||||
|
# ---- SAFETY-24's own defeats ------------------------------------------------
|
||||||
|
# 8. the SoC gate removed altogether - i.e. the guard exactly as SAFETY-07
|
||||||
|
# shipped it. Re-derived on the same modelled pack, not described.
|
||||||
|
MUT.append(("the SoC gate removed - SAFETY-07's unbounded forced discharge",
|
||||||
|
BEFORE["min_soc"] <= BMS_FLOOR and AFTER["min_soc"] > BMS_FLOOR,
|
||||||
|
"part 9: 'the forced discharge STOPS AT THE CONFIGURED FLOOR'"))
|
||||||
|
|
||||||
|
# 9. an unreadable SoC read as a full battery. THE DISARMING DIRECTION, and the
|
||||||
|
# one this project has actually shipped once (NOTES.md 4.4b).
|
||||||
|
MUT.append(("`soc or 100` - an unreadable SoC read as a full battery",
|
||||||
|
discharge_permitted(float(None or 100), SOC_FLOOR) is None
|
||||||
|
and d_unread.floor_limited,
|
||||||
|
"part 8: 'an unreadable SoC is refused, and refused with its OWN "
|
||||||
|
"reason'"))
|
||||||
|
|
||||||
|
# 10. the gate moved ABOVE the `wanted > 0` test, i.e. graded where it is
|
||||||
|
# degenerate. Counted on the healthy day rather than argued: every quiet
|
||||||
|
# cycle would report the peak was about to be exceeded.
|
||||||
|
_early = sum(1 for _t in (500.0, 1000.0, 2000.0)
|
||||||
|
if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None
|
||||||
|
and not decide(PeakTracker.project(
|
||||||
|
averager_at(_t, 450.0), B + timedelta(seconds=450)),
|
||||||
|
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||||
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
|
soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged)
|
||||||
|
MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet "
|
||||||
|
f"quarters would have alarmed)",
|
||||||
|
_early > 0 and not d_quiet.floor_limited,
|
||||||
|
"part 8: 'an empty pack on a quarter that does not need shaving is "
|
||||||
|
"IDLE, not floor-limited'"))
|
||||||
|
|
||||||
|
# 11. rule 8 written `<` instead of `<=`, so a floor EQUAL to the BMS cut-out
|
||||||
|
# is accepted and buys zero margin.
|
||||||
|
MUT.append(("validate rule 8 written `<` instead of `<=`",
|
||||||
|
len(rule8(validate_startup_config(
|
||||||
|
opts24(peak_soc_floor=BMS_FLOOR)))) == 1,
|
||||||
|
"part 9: 'so is a floor EXACTLY AT it, which is the interesting "
|
||||||
|
"case'"))
|
||||||
|
|
||||||
|
# 12. the floor-limited branch filing `wanted` anyway - a report with no action,
|
||||||
|
# which is the shape a "just log it" review comment produces.
|
||||||
|
MUT.append(("floor-limited reported but the floor still filed at `wanted`",
|
||||||
|
near(d_lim.floor_w, 0.0) and d_lim.wanted_w > 0.0,
|
||||||
|
"part 8: 'at the floor it STOPS, and the floor it files is 0 W'"))
|
||||||
|
|
||||||
|
# 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than
|
||||||
|
# a defect. Stated, because the alternative is a reader believing this card
|
||||||
|
# bounds more than it does.
|
||||||
|
_loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9)
|
||||||
|
MUT.append(("the grid-following loop discharging past this floor",
|
||||||
|
not _loop_unbounded,
|
||||||
|
"NOTHING, ON PURPOSE - this card bounds THE PEAK GUARD's forced "
|
||||||
|
"discharge, the thing SAFETY-07 introduced. SAFETY-18's finding "
|
||||||
|
"that the loop itself has no SoC guard is still open, and a `lo` "
|
||||||
|
"limit is structurally incapable of closing it"))
|
||||||
|
|
||||||
for label, caught, where in MUT:
|
for label, caught, where in MUT:
|
||||||
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
|
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
|
||||||
print(f" {where}")
|
print(f" {where}")
|
||||||
check("the six mutations that CAN disarm this guard are each caught by a check "
|
check("the eleven mutations that CAN disarm these guards are each caught by a "
|
||||||
"above - a guard whose checks survive its own defeat is not a guard",
|
"check above - a guard whose checks survive its own defeat is not a "
|
||||||
all(m[1] for m in MUT[:6]))
|
"guard", all(m[1] for m in MUT[:6]) and all(m[1] for m in MUT[7:12]))
|
||||||
check("...and the seventh is honestly reported as not caught, because it cannot "
|
check("...and the seventh is honestly reported as not caught, because it cannot "
|
||||||
"disarm anything: `max(cap, peak)` makes a zero peak the strictest "
|
"disarm anything: `max(cap, peak)` makes a zero peak the strictest "
|
||||||
"ceiling there is, so the disarming direction is closed by the shape of "
|
"ceiling there is, so the disarming direction is closed by the shape of "
|
||||||
@@ -1261,6 +1937,11 @@ check("...and the seventh is honestly reported as not caught, because it cannot
|
|||||||
"same zero into FULL freedom, which part 3 shows is why headroom_w() is "
|
"same zero into FULL freedom, which part 3 shows is why headroom_w() is "
|
||||||
"not consumed here", not MUT[6][1]
|
"not consumed here", not MUT[6][1]
|
||||||
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
|
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
|
||||||
|
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||||
|
"THIS CARD: the grid-following loop can still take the pack past this "
|
||||||
|
"floor, because the floor bounds the peak guard's own forced discharge "
|
||||||
|
"and a `lo` limit cannot cap a discharge. Saying so here is worth more "
|
||||||
|
"than a green line that would be untrue", not MUT[12][1])
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ def shipped(key):
|
|||||||
if in_schema or not line.startswith(f" {key}:"):
|
if in_schema or not line.startswith(f" {key}:"):
|
||||||
continue
|
continue
|
||||||
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
|
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
|
||||||
|
# OPT-04 added the first BOOLEAN price option, and without this the
|
||||||
|
# mirror check would compare the string "false" against `False` and go
|
||||||
|
# red for a file that is perfectly in step. Parsing a bool as a bool is
|
||||||
|
# what the yaml module this image does not carry would have done; it
|
||||||
|
# widens nothing, because a key whose shipped value disagrees with the
|
||||||
|
# mirror still drifts.
|
||||||
|
if raw in ("true", "false"):
|
||||||
|
return raw == "true"
|
||||||
try:
|
try:
|
||||||
return int(raw)
|
return int(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -0,0 +1,911 @@
|
|||||||
|
"""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.arbitrage import ArbitrageDecision
|
||||||
|
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__,
|
||||||
|
# ⚠️ OPT-04's outcome type is scanned HERE, in the same dict, rather
|
||||||
|
# than by a hand-written case in test_arbitrage.py - which is TEST-14's
|
||||||
|
# actual instruction. A new outcome type extends the SCAN; a
|
||||||
|
# hand-written case is the thing that gets forgotten, and the whole
|
||||||
|
# reason this dict exists instead of five separate checks.
|
||||||
|
"ArbitrageDecision.__init__": ArbitrageDecision.__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())
|
||||||
|
# ⚠️ THIS LIST IS A DRIFT PIN, NOT A BAN ON EVER ADDING AN OPTION. FCST-03 added
|
||||||
|
# none, and said so here. OPT-04 added FOUR - and added them to this ONE dict
|
||||||
|
# and to config.yaml, which is precisely the behaviour CFG-03 asks for: the
|
||||||
|
# alternative is a fourth freehand copy in main.py's `o.get(k, X)` fallbacks.
|
||||||
|
# The pin is what makes such an addition a decision somebody signs rather than a
|
||||||
|
# thing that appears; whoever adds the fifth comes here and says which card and
|
||||||
|
# why, as this line does.
|
||||||
|
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||||
|
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
|
||||||
|
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
|
||||||
|
f"keys - FCST-03 still added none of its own {_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"] + _OPT04_KEYS))
|
||||||
|
# ...and the four are genuinely NEW, not a rename of something that was already
|
||||||
|
# there, or the line above would pass while the mirror silently lost a key.
|
||||||
|
check("...and OPT-04's four keys are additions rather than replacements: the "
|
||||||
|
"nine TARIFF-02/03 keys are all still present",
|
||||||
|
isinstance(_defaults, list)
|
||||||
|
and len(set(_defaults) - set(_OPT04_KEYS)) == 9)
|
||||||
|
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}",
|
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
||||||
|
|
||||||
_v_fixed = p_fixed.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)
|
_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("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 "
|
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",
|
"low and sell high, not because a FIXED enum was matched on",
|
||||||
@@ -327,7 +327,7 @@ _flat_dayahead = TariffPlan(
|
|||||||
None, "provider")
|
None, "provider")
|
||||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||||
"is arithmetic and not a product label",
|
"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.
|
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
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.
|
# §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 "
|
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||||
"the peak guard is holding",
|
"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",
|
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 "
|
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||||
"and keyword-only, so a spread is not obtainable without answering it",
|
"and keyword-only, so a spread is not obtainable without answering it",
|
||||||
raises(lambda: p_two.arbitrage(), TypeError)
|
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",
|
"cannot run against it",
|
||||||
raises(lambda: _none.read.points, Absence))
|
raises(lambda: _none.read.points, Absence))
|
||||||
check("...and arbitrage is refused, per §11.4",
|
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)
|
and arbitrage_allowed(_none.read) is False)
|
||||||
check("...and static_status() says so in words rather than reporting a price",
|
check("...and static_status() says so in words rather than reporting a price",
|
||||||
static_status(opts(None), THU) == (False, "no static profile"))
|
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))
|
_fb.provisional and plan_is_provisional(_fb.read))
|
||||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||||
"exists and has a real spread",
|
"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))
|
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||||
check("...while the SAME profile as the configured product allows it - so the "
|
check("...while the SAME profile as the configured product allows it - so the "
|
||||||
"refusal is about provisionality and not about the profile",
|
"refusal is about provisionality and not about the profile",
|
||||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
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 "
|
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",
|
"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)))
|
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)
|
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||||
"feed rather than from the profile",
|
"feed rather than from the profile",
|
||||||
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
|
_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).spread_eur_kwh,
|
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
|
||||||
0.023, 1e-9))
|
0.023, 1e-9))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user