Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04292c4a08 | ||
|
|
86c43eba24 | ||
|
|
d57122ec74 | ||
|
|
4601682815 | ||
|
|
9234016419 | ||
|
|
b7272169b1 | ||
|
|
2b8b8cb97f | ||
|
|
8e589fee3f | ||
|
|
bdd7f18a54 | ||
|
|
a1376e44e4 | ||
|
|
e709554857 | ||
|
|
a30bde1ab1 | ||
|
|
71cd7849e4 | ||
|
|
e7c95bfd8a | ||
|
|
dd40c56ab4 | ||
|
|
c2ed509565 | ||
|
|
2d570d84ce | ||
|
|
cf5094c608 | ||
|
|
9f8b721a4d | ||
|
|
303525cfc5 | ||
|
|
616b62d3e9 | ||
|
|
91ea676af4 | ||
|
|
862f0fa574 | ||
|
|
e102cde841 | ||
|
|
0dbd485b5a | ||
|
|
f4914a174a | ||
|
|
852fc7ced6 | ||
|
|
9aadf469bc |
@@ -2,6 +2,570 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Thirty days of the real meter, and the acceptance criterion three cards
|
||||
deferred is now a number (DATA-01).** TARIFF-01 and SAFETY-07 both merged with
|
||||
AC 3 - *"monthly peak held under the soft limit in >= 95 % of intervals over a
|
||||
30-day simulation replay"* - honestly unmet, on the belief that only two days of
|
||||
capture existed and nothing longer could be produced. That belief came from a
|
||||
true statement about the past being read as one about the future: HA's recorder
|
||||
still held the whole month. `scenarios/p1-net-30d-2026-08/` is 2026-08-01 to
|
||||
2026-08-30, **485,841 samples, 30/30 days read, 0 failed, 4.13 MB gzipped**, in
|
||||
the same schema as the two days that were already there.
|
||||
|
||||
`tools/export_p1_history.py` is the exporter, committed and runnable - the
|
||||
2026-08-25 one-off was not, and that is exactly why the data was thought
|
||||
unobtainable. It reads day-at-a-time, retries, and **never lets a failed read
|
||||
look like an empty hour**: a day whose read fails writes no file, is recorded as
|
||||
`"read": "FAILED"`, and the run exits non-zero; the entity is proved to exist
|
||||
before any empty day is believed. Nothing is interpolated, so the month's 13
|
||||
gaps (1291 s, 99.95 % covered) and its 3 `unavailable` samples stay visible.
|
||||
`--verify` re-fetches the two committed days by the same route and reproduces
|
||||
them **byte for byte** after decompression.
|
||||
|
||||
`tools/replay_ac3.py` runs the month. Over 30 days the criterion measures
|
||||
**98.89 % uncontrolled and 98.99 % guarded**, so it is met on that reading - and
|
||||
the uncontrolled figure is the finding: a month with no controller at all passes
|
||||
it. On the other reading of the same sentence it is not met - the guard takes
|
||||
August's peak from **5029.8 W to 4107.3 W**, still over the 3500 W soft limit,
|
||||
because once a month peak is set `CEILING_MONTH_PEAK` defends that peak rather
|
||||
than the cap. Neither number is hidden; both are in that directory's README.
|
||||
|
||||
The size decision is committed and measured, not absorbed: a `git bundle` of
|
||||
the whole history goes from **1.58 MB to 5.28 MB** (+3.70 MB, x3.3), which a
|
||||
Home Assistant supervisor clones on every install. Downsampling was not
|
||||
available - `meter_max_age_s` is 30 s, so any coarser resample converts real
|
||||
coverage into modelled meter-gap and moves the number being measured.
|
||||
|
||||
The thirty files sit in a SUBDIRECTORY on purpose: `test_tariff.py` part 9 and
|
||||
`test_peakguard.py` part 6 glob `scenarios/p1-net-*.jsonl.gz` non-recursively,
|
||||
so CI keeps replaying the two small days and does not grow ~50 s per PR. Both
|
||||
files' honesty notes are corrected to point at the month.
|
||||
|
||||
**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
|
||||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||||
built on delivered power cannot.
|
||||
|
||||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||||
healthy reference day it engages on zero of 95 quarters; the action is
|
||||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||||
still TARIFF-01's open item and this does not claim it.
|
||||
|
||||
**Blindness forbids importing, and it is checked before the projection is
|
||||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||||
on this cycle's standing command, not a second copy of the conjunction.
|
||||
|
||||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||||
depending on that coincidence is how a future last-known-peak field would silently
|
||||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||||
|
||||
**Two defects found building it, both in the interaction rather than the
|
||||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||||
every limit at or above the winner's priority and takes the most restrictive — so
|
||||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||||
maintenance shaping while the guard is engaged, which is what that branch already
|
||||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||||
install, so until now the suspension could not fire on the site that needed it.
|
||||
And thresholding `excess > 0` released the claim the instant it started working,
|
||||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||||
|
||||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||||
|
||||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||||
and the achievable discharge comes from `max_w` and the device rating.
|
||||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||||
no telegrams, so the guard does not run at all and the status page says NOT
|
||||
COVERED — running it there would have blinded it permanently and stopped every
|
||||
legacy install from charging, which is what the first cut did until
|
||||
`test_clamp.py`'s export case caught it.
|
||||
|
||||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||||
`static_profile` option describes a two-period day/night tariff — or a single
|
||||
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
|
||||
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
|
||||
so nothing downstream learns that a tariff can be static.
|
||||
|
||||
**It is not a sixth provider plugin, and the reason is mechanical.**
|
||||
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
|
||||
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
|
||||
products with two different required behaviours, and which one a site is on is a
|
||||
property of the operator's data, not of any class. `StaticProfile.product`
|
||||
derives it from the number of distinct prices instead. The second reason: the
|
||||
plugin framework exists to stop a *fetched* figure being marked up twice, and a
|
||||
number the operator typed as all-in has no publisher to disagree with.
|
||||
|
||||
**So the profile sits where §11.4 already says it does — under the providers, as
|
||||
the fallback.** The configured-product path and the feed-is-down path are the
|
||||
same generator differing in one flag: `provisional`. A static-tariff site runs
|
||||
that path every day, so the degradation path for a dynamic site cannot rot
|
||||
unused.
|
||||
|
||||
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
|
||||
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
|
||||
spread and the cheapest and dearest hours, and the *refused* arm carries a
|
||||
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
|
||||
answering 0.0. An optimiser cannot compute a trade from a refusal because it
|
||||
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
|
||||
holding, there is no price data, the plan is provisional, or **the series is
|
||||
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
|
||||
arithmetic rather than by matching on an enum, and a dynamic feed that happens
|
||||
to print one price all day is refused for the same true reason.
|
||||
|
||||
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
|
||||
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
|
||||
spread is not obtainable without first answering whether §10.1's priority 2 is
|
||||
holding. §9.1's capacity tariff is a network cost charged whatever the energy
|
||||
product is.
|
||||
|
||||
**Time-window shifting reaches the one shiftable load that exists.** main.py's
|
||||
cheap-window flag — previously fed only by the `price_now_entity` /
|
||||
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
|
||||
gained a **Tariff** row naming the window, the price, when it next changes and
|
||||
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
|
||||
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
|
||||
would top up from the grid at any hour, which is the opposite of
|
||||
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
|
||||
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
|
||||
cheap or dear depending on when you asked.
|
||||
|
||||
**A profile must tile every day type exactly once, checked at startup.** A gap
|
||||
is refused with the uncovered range named rather than filled in — an invented
|
||||
price is one an optimiser would trade on — and an overlap is refused because
|
||||
otherwise one of two prices wins silently on list order. The trap this catches
|
||||
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
|
||||
a window applies on the day the clock reads.
|
||||
|
||||
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
|
||||
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
|
||||
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
|
||||
wording in the same commit when it went red. It went red naming
|
||||
`static_tariff.py`. The message now says what is true instead — the series *is*
|
||||
consumed, but main.py acts on the static side of it and not on a fetched one —
|
||||
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
|
||||
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
|
||||
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
|
||||
`static_profile` alongside the Home Assistant price pair is the same
|
||||
silent-loser conflict, and a live one.
|
||||
|
||||
**No new place for an option default (CFG-03 is not widened).**
|
||||
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
|
||||
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
|
||||
than to a defaults dict in the new module, which has none. main.py grew no
|
||||
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
|
||||
here so a reviewer does not have to infer it.
|
||||
|
||||
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
|
||||
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
|
||||
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
|
||||
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
|
||||
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
|
||||
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
|
||||
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
|
||||
night-priced, which is the weekend row proving itself on real data. §17 names
|
||||
six Belgian scenarios and a static-profile case is not among them; this is the
|
||||
seventh, and it is two captured days rather than a season — the file says so
|
||||
rather than implying an annual figure.
|
||||
|
||||
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
|
||||
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
|
||||
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
|
||||
for the tariff alone on such a site. That is a product finding, not a defect.
|
||||
|
||||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||||
that follows never learns a supplier's name.
|
||||
|
||||
**Double-counting the markup is refused rather than discouraged, given a
|
||||
correct declaration.** A plugin does not return a price; it returns the figure
|
||||
its source published and declares which of {energy, markup, taxes} that figure
|
||||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||||
publish a retail figure with their margin already inside; adding the configured
|
||||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||||
with nothing reporting it.
|
||||
|
||||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||||
public dict in the module every plugin already imports. So `register()` now also
|
||||
**refuses at import any plugin whose own code mentions a framework key**
|
||||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||||
EUR/kWh; every attack landed 0.219.
|
||||
|
||||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||||
still comes out one markup high while touching no framework name, and no code
|
||||
can catch that - the declaration is the plugin's unverifiable claim about
|
||||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||||
truthfulness.
|
||||
|
||||
**The product type is declared, not inferred from the plugin's name.**
|
||||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||||
thresholds; that reaches a consumer through `capabilities().product` and
|
||||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||||
mechanically that no plugin id appears anywhere in the framework's own code,
|
||||
and drives a sixth provider defined inside the test file to the correct regime
|
||||
with `price.py` untouched.
|
||||
|
||||
**"No price data" is a type, not an empty list.** Following the same rework
|
||||
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
|
||||
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
|
||||
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
|
||||
`.get("points", [])` raises rather than returning the default. **A day of free
|
||||
electricity and a day with no prices are different instructions** - arbitrage is
|
||||
pointless under the first and forbidden under the second - and an implementation
|
||||
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
|
||||
failure returns `no_data`; nothing raises into the control tick.
|
||||
|
||||
**What is not verified, and cannot be by an offline suite:** every fixture is
|
||||
synthetic, so the five feed URLs and JSON shapes are checked against a belief
|
||||
about what those suppliers publish, not against the feeds. The parse layer is
|
||||
exercised; the contract with each supplier is not. Expect the first live fetch
|
||||
of each to need a shape fix, and note that a wrong shape degrades to
|
||||
no-price-data rather than to a wrong price.
|
||||
|
||||
**`manual_csv` is the documented offline fallback** and needs no network, no
|
||||
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
|
||||
column must carry a UTC offset - a bare local timestamp is refused, because one
|
||||
hour every October names two moments.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
|
||||
than a second series, and negative values are accepted. Consumption plugins
|
||||
cannot reach it.
|
||||
|
||||
**`price_provider` may not be combined with `price_now_entity` /
|
||||
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
|
||||
Assistant sensors are the existing delegation of pricing to HA and still work
|
||||
alone; with both configured they would silently keep deciding while the status
|
||||
page named a supplier that decided nothing.
|
||||
|
||||
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
|
||||
Brussels offsets are computed from the EU rule and every instant is carried as
|
||||
UTC and rendered at the offset that applies to it. The checks drive both 2026
|
||||
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
|
||||
fixed 24-slot implementation and a correct one agree.
|
||||
|
||||
**The capacity-tariff peak is now measured, banked and survives a restart
|
||||
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
|
||||
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
|
||||
blocks, time-weighted over irregular telegram spacing, offtake-only and
|
||||
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
|
||||
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
|
||||
the indefinite 15-min tier, one row per calendar month in the permanent peak
|
||||
tier, and no `DELETE` against either anywhere in the module.
|
||||
|
||||
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
|
||||
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
|
||||
continuously updated, not a trailing-15-minutes window - a trailing window
|
||||
reports a peak the meter never bills, and reports it HIGH, because a burst
|
||||
straddling :15 fills it completely while contributing to two block averages at
|
||||
partial weight.
|
||||
|
||||
**A restart mid-block resumes it, and a restart that outlives the block banks
|
||||
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
|
||||
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
|
||||
checkpoint in the same breath as a block close. The held sample keeps its
|
||||
ORIGINAL timestamp across the restart, so the outage is integrated under the
|
||||
same `max_hold_s` rule a running process would apply and uncovered seconds drag
|
||||
the closed block DOWN rather than being held forward. `quarter_start` is the
|
||||
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
|
||||
double-count and a partial re-run cannot lower a quarter already measured.
|
||||
|
||||
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
|
||||
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
|
||||
`riglock.holder()` fix applied to something that spends money. A peak read as
|
||||
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
|
||||
moment its own storage breaks, so there is no accessor that defaults it: a
|
||||
caller who forgets gets a `TypeError`, and the status page prints a different
|
||||
row for "no quarter yet" than for "the database did not answer".
|
||||
|
||||
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
|
||||
31 August bills in September. Block alignment still needs no timezone.
|
||||
|
||||
**No new option, and no second peak entity.** The soft limit is the existing
|
||||
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
|
||||
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
|
||||
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
|
||||
|
||||
**It measures and nothing else.** No claim, no clamp, no command; graded on the
|
||||
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
|
||||
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
|
||||
ever sees `P1Sample`, the meter's own reading, never a setpoint.
|
||||
|
||||
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
|
||||
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
|
||||
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
|
||||
the percentage under the shipped limit next to the sample size in days, and
|
||||
asserts nothing about the 95 % figure.
|
||||
|
||||
**A computed setpoint over the rating now alarms instead of being clipped in
|
||||
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
|
||||
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
|
||||
|
||||
+313
-8
@@ -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_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_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_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_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 |
|
||||
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
@@ -439,6 +441,11 @@ maintenance schedule.
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||||
capped — see "The capacity-peak guard" below — and what follows is kept because it
|
||||
is still the clearest statement of WHAT is being guarded and why the number is
|
||||
worth what it is.**
|
||||
|
||||
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
|
||||
grid-following loop is bounded by `max_w` and by the device rating, and by
|
||||
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
|
||||
@@ -463,16 +470,107 @@ What exists today, and what does not:
|
||||
|---|---|
|
||||
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||||
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||||
| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 |
|
||||
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||
| Frozen meter *stopped* | ❌ — the alarm does not command anything |
|
||||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||||
|
||||
So the frozen-meter alarm below **narrows the window; it does not close it.** On
|
||||
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
|
||||
today is operational: set `max_w` no higher than the loop actually needs (the
|
||||
tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit
|
||||
the e-stop, and watch for the alarm.
|
||||
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||||
quarter-hour average to guard, and the status page says **NOT COVERED**. There
|
||||
the mitigation is still operational: set `max_w` no higher than the loop actually
|
||||
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
|
||||
there), fit the e-stop, and watch for the alarm.
|
||||
|
||||
### The capacity-peak guard
|
||||
|
||||
**What it does.** Every cycle the add-on projects the open quarter to its
|
||||
end-of-interval average — from P1 telegrams only, never from the command — and
|
||||
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
|
||||
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
|
||||
a hard clamp on charging while the bound sits above the standing charge, and
|
||||
pre-emptive discharge while it sits above zero. It can never cap discharge or
|
||||
export, because a floor cannot.
|
||||
|
||||
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
|
||||
the floor is the **measured** battery power plus that. The 900/remaining factor
|
||||
is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||||
and becomes aggressive only once the quarter is nearly measured.
|
||||
|
||||
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||||
already stands higher — once a month's bill is set at 5013 W, holding later
|
||||
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||||
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||||
ceiling at the cap, so a broken instrument makes the guard stricter, never looser.
|
||||
|
||||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||
are checked before the projection is graded: no open quarter (a fresh averager
|
||||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||
and commands nothing, and this stops the import.
|
||||
|
||||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||
The action is level-triggered and unlatched — re-derived from a fresh projection
|
||||
every cycle, released the instant the projection drops back — so a false
|
||||
engagement costs one cycle of discharge rather than a parked battery. And it is a
|
||||
LIMIT, so every stop in the watchdog table still outranks it.
|
||||
|
||||
**Replayed on 2026-08-20**, the day the vendor controller failed: the billed
|
||||
quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the
|
||||
guard brings it to **3485 W** — under the soft limit, without ever asking for more
|
||||
than the inverter can give. Across both committed days the quarters under the
|
||||
limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3;
|
||||
it is the comparison two days can support.
|
||||
|
||||
**What it alarms on is saturation, not engagement.** An engagement is an ordinary
|
||||
control action and goes to the action log and the status page. A *saturated*
|
||||
guard — asking for everything the inverter has while the quarter is still
|
||||
projected over — raises one notification (`goodwe_peak_saturated`), because that
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
|
||||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||
rating — every one of them a key that already exists.
|
||||
|
||||
#### The 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)
|
||||
|
||||
@@ -581,6 +679,213 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
|
||||
parked at 0 W and the two halves of the rule cannot both be true. At the schema
|
||||
floor of 60 s that figure is 60 W and only the margin is left.
|
||||
|
||||
### Energy price providers
|
||||
|
||||
> **A fetched price moves energy only when you switch arbitrage on.** This
|
||||
> release ships the five provider plugins, the canonical all-in format they
|
||||
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||
> shipped default, turning a provider on still fetches, validates and publishes
|
||||
> prices and changes no charging decision.
|
||||
>
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the five plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
|
||||
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
|
||||
**Which plugin sources what.** This is the one thing to get right, because
|
||||
adding your markup to a price that already contains it makes every future
|
||||
arbitrage decision wrong by that amount, in a direction nothing reports.
|
||||
|
||||
| plugin | product | the published figure contains | the add-on adds |
|
||||
|---|---|---|---|
|
||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
|
||||
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
|
||||
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
|
||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||
|
||||
You cannot get this wrong by editing the markup: each plugin *declares* what its
|
||||
feed contains and the add-on adds only the rest. Set a markup under a plugin
|
||||
that already carries one and it is **ignored, with a line in the log saying so**
|
||||
— never applied twice.
|
||||
|
||||
That holds **given a correct declaration**, and it is worth knowing where the
|
||||
line is. What the add-on enforces mechanically is that a plugin cannot obtain
|
||||
the markup or apply it: the numbers are not reachable from plugin code, and a
|
||||
plugin whose code mentions them fails to load at all. What it cannot check is
|
||||
whether a plugin described its supplier's feed truthfully — that is a claim
|
||||
about somebody else's tariff, and no code can verify it. If you write a plugin,
|
||||
the `sources` declaration is the part a reviewer has to read.
|
||||
|
||||
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
|
||||
flagged as high-volatility, which widens arbitrage thresholds once the
|
||||
optimiser lands. That comes from the plugin's own declaration, not from its
|
||||
name.
|
||||
|
||||
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
|
||||
account and no HTTP stack at all, and it is the supported way to run this
|
||||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||||
point `price_csv_path` at it:
|
||||
|
||||
```csv
|
||||
start,eur_per_kwh,provisional
|
||||
2026-01-15T00:00:00+01:00,0.1834,false
|
||||
2026-01-15T01:00:00+01:00,-0.0210,true
|
||||
2026-01-15T02:00:00+01:00,0.3115,false
|
||||
```
|
||||
|
||||
- `start` **must carry its UTC offset** (`+01:00` in winter, `+02:00` in
|
||||
summer). A bare `2026-01-15 00:00` is refused, because for one hour every
|
||||
October a local wall clock names two different moments.
|
||||
- `eur_per_kwh` is **all-in** — energy *plus* markup *plus* taxes. This plugin
|
||||
has no way to know what your numbers already include, so it assumes
|
||||
everything and adds nothing.
|
||||
- Negative prices are valid and are kept. They are the best charging hours of
|
||||
the year.
|
||||
- `provisional` is optional; omit the column and every hour is treated as
|
||||
settled.
|
||||
|
||||
**When there are no prices at all** — provider off, feed unreachable, CSV
|
||||
missing — the add-on does not guess. It reports *no price data*, which is a
|
||||
distinct state from "prices, all of them zero": the plan is marked provisional
|
||||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||
price of zero, which would read as free electricity.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||
often near zero, which by itself strongly favours self-consumption over export.
|
||||
It is a single number rather than a curve because no supported supplier
|
||||
publishes a dynamic injection tariff; negative values are accepted.
|
||||
|
||||
**The old Home Assistant price sensors.** `price_now_entity` and
|
||||
`price_avg_entity` still work and are unchanged: the add-on compares them and
|
||||
uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
**not compatible with `price_provider`, and not with `static_profile` either**
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
### Static day/night and fully-fixed tariffs (`static_profile`)
|
||||
|
||||
Most Belgian households are **not** on a dynamic contract, and an EMS that only
|
||||
works under hourly pricing is an EMS for a minority. `static_profile` is how a
|
||||
two-period day/night tariff or a single fixed price becomes a first-class
|
||||
configuration rather than "turn the price feature off and hope".
|
||||
|
||||
It is one option, holding a JSON object, on one line:
|
||||
|
||||
```json
|
||||
{"periods": [
|
||||
{"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
||||
{"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
||||
{"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
||||
```
|
||||
|
||||
A **fully fixed** tariff is one period covering the whole week:
|
||||
|
||||
```json
|
||||
{"periods": [
|
||||
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
|
||||
```
|
||||
|
||||
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
|
||||
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
|
||||
`price_taxes_eur_kwh` are **not** added to it.
|
||||
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
|
||||
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
|
||||
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
|
||||
includes 07:00 and excludes 22:00. A window may cross midnight.
|
||||
- The clock changes twice a year and the add-on handles it. It generates prices
|
||||
against absolute instants, so a spring-forward day is 23 hours and an
|
||||
autumn-back day is 25.
|
||||
|
||||
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
|
||||
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
|
||||
> Saturday 03:00 is Saturday's. That is why the example above has a third row
|
||||
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
|
||||
> uncovered hours.
|
||||
|
||||
**Every day type must be covered exactly once.** The periods have to tile all
|
||||
seven days with no gap and no overlap, and the add-on checks that at startup. A
|
||||
gap is refused rather than filled in, because a made-up price is one the
|
||||
optimiser would trade on; an overlap is refused because otherwise one of the two
|
||||
prices silently wins on the order you typed them in.
|
||||
|
||||
**What it changes today.** The add-on works out which window you are in and
|
||||
whether that window is *cheap* — strictly below the tariff's seven-day average.
|
||||
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
|
||||
cheap-window grid top-up uses it, which is exactly the "shift load into the
|
||||
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
|
||||
on a price yet.
|
||||
|
||||
Under a **fully fixed** tariff every hour equals the average, so there is *no*
|
||||
cheap window by construction and the add-on runs self-consumption only — which
|
||||
is the correct behaviour for that product, not a limitation. Peak shaving, the
|
||||
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
|
||||
unaffected: the capacity tariff is a network cost charged whatever your energy
|
||||
contract is.
|
||||
|
||||
**Recommended even on a dynamic contract.** When a price feed is unreachable the
|
||||
add-on falls back to your static profile, marks the plan provisional and
|
||||
disables arbitrage. With `static_profile` empty there is nothing to fall back
|
||||
to, and the site simply has no tariff until the feed returns. It is not required
|
||||
— the add-on warns rather than refusing — but filling it in is what makes that
|
||||
fallback work.
|
||||
|
||||
### 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
|
||||
|
||||
| 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_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
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
|
||||
|
||||
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,23 +28,30 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import arbitrage
|
||||
from .arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||
)
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
|
||||
)
|
||||
from .hass import HomeAssistant
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
from .mqtt import MqttPublisher
|
||||
from . import peakguard
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
||||
from . import web
|
||||
@@ -52,6 +59,34 @@ from . import web
|
||||
OPTIONS_PATH = "/data/options.json"
|
||||
_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:
|
||||
try:
|
||||
@@ -63,7 +98,8 @@ def load_options() -> dict:
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop):
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db"):
|
||||
self.o = opts
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
@@ -163,6 +199,56 @@ class Controller:
|
||||
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
||||
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
||||
self.p1_enabled = is_enabled(opts)
|
||||
|
||||
# TARIFF-01. The capacity-tariff peak: measured from the meter, banked
|
||||
# in SQLite, restored across a restart. It is an INSTRUMENT - it files no
|
||||
# claim and clamps nothing. SAFETY-07 is what will read it.
|
||||
#
|
||||
# ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped,
|
||||
# already validated, already read by control.peak_at_risk(). CFG-03
|
||||
# tracks three places option defaults live; a fourth, for a limit that
|
||||
# already exists, is the mistake the clamp watchdog declined to make ~30
|
||||
# lines up. config.yaml and validate.py are untouched by this ticket.
|
||||
#
|
||||
# ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing
|
||||
# HA-side delegation and it stays the input to maintenance_charge_floor():
|
||||
# repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY
|
||||
# during a maintenance charge, which belongs to SAFETY-07 with its own
|
||||
# acceptance, not to a measurement ticket. What this adds is the MEASURED
|
||||
# MONTH PEAK - a different quantity from a forecast entity, published
|
||||
# nowhere as a duplicate of it.
|
||||
self.peak_store = PeakStore(peak_db_path)
|
||||
self.peak = PeakTracker(self.peak_store,
|
||||
cap_w=float(opts.get("peak_cap_w", 3500)))
|
||||
self.peak.restore(self.p1.averager)
|
||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||
|
||||
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||
# design (peakguard.py holds no history between calls), so the only
|
||||
# state here is what an EDGE needs: what the last cycle concluded, so
|
||||
# the action log and the notification fire on transitions rather than
|
||||
# once per cycle for a quarter of an hour.
|
||||
#
|
||||
# ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp
|
||||
# watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`,
|
||||
# already shipped and already validated, and every other term of the
|
||||
# projection is read off something the arbiter or the averager already
|
||||
# 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_engaged = False
|
||||
self._peak_saturated = False
|
||||
self._peak_floor_limited = False
|
||||
self._maint_shape_suspended = False
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
# None when P1 is off, or under a transport that has no such counter.
|
||||
@@ -183,6 +269,20 @@ class Controller:
|
||||
self.grid = self.soc = self.batt = None
|
||||
self.peak_fc = None
|
||||
self.cheap = False
|
||||
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||
# no" is an evidenced answer rather than a bare no.
|
||||
self.tariff_words = "no static profile"
|
||||
|
||||
# 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_at = None
|
||||
self.last_write_ok = None
|
||||
@@ -236,9 +336,31 @@ class Controller:
|
||||
now_p = await self.hass.number(now_e)
|
||||
avg_p = await self.hass.number(avg_e)
|
||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||
self.tariff_words = "Home Assistant price entities"
|
||||
else:
|
||||
# Fixed-tariff site: never force a paid grid top-up, wait for sun.
|
||||
self.cheap = False
|
||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
||||
# The add-on has exactly one shiftable load in 0.5.x - the
|
||||
# maintenance cycle's cheap-window grid top-up, ~15 lines below in
|
||||
# cycle() - so this is where the window reaches something that moves
|
||||
# energy. With `static_profile` empty this returns False and the
|
||||
# behaviour is bit-for-bit what it has always been: "fixed-tariff
|
||||
# site, never force a paid grid top-up, wait for sun".
|
||||
#
|
||||
# ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed
|
||||
# resolver (static_tariff.resolve_tariff) can perform an HTTP fetch,
|
||||
# and this coroutine is on the path that feeds the ESP32 heartbeat -
|
||||
# a supplier's TLS timeout here would be a supplier able to push the
|
||||
# add-on towards layer 1's 30 s veto. Generation from a static
|
||||
# profile is pure arithmetic, so §2.3's "regenerate on the tick"
|
||||
# applies and there is nothing to block on. Putting a fetched series
|
||||
# on this path needs a BACKGROUND refresh and belongs with OPT-04.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
|
||||
# alongside the two Home Assistant entities, because this `if` picks
|
||||
# one and the loser is silent.
|
||||
self.cheap, self.tariff_words = static_status(
|
||||
o, datetime.now(timezone.utc))
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
@@ -372,6 +494,146 @@ class Controller:
|
||||
self.log_event("inputs missing - commanding 0 W")
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
|
||||
|
||||
# --- SAFETY-07: the quarter-peak projection guard --------------------
|
||||
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
|
||||
# What is decided HERE and nowhere else is what the guard is allowed to
|
||||
# see and what it is allowed to ask for:
|
||||
#
|
||||
# ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON
|
||||
# `self.freeze_wd.active`. The watchdog object is advanced at the END of
|
||||
# cycle() (check_meter_frozen), so reading its level here would grade
|
||||
# this quarter against the PREVIOUS cycle's conjunction - one cycle of
|
||||
# lag, in a guard whose entire job is to act inside a 900 s quarter.
|
||||
# `standing` is the value SAFETY-02 already computed above for exactly
|
||||
# this meaning: the command on the wire that this meter reading is a
|
||||
# response to. The predicate itself is freeze.py's shipped
|
||||
# `looks_frozen()`, not a copy of it - see loop_is_following() on what a
|
||||
# transcribed safety predicate costs.
|
||||
#
|
||||
# ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE
|
||||
# SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and
|
||||
# resolves to 0 W - the battery parked, in the one minute of the month it
|
||||
# should be discharging hardest. maintenance_charge_w had to learn the
|
||||
# same lesson (config.yaml, validate.py rule 6); this claim is built so
|
||||
# the config cannot express it.
|
||||
#
|
||||
# ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A
|
||||
# claim is not sticky. Filed once and skipped next cycle, the floor
|
||||
# lapses and the actuator goes silently back to whatever was pushing the
|
||||
# peak. Which of the higher-priority claims outranks it is arbiter.py's
|
||||
# business, not this block's - and the answer is already right: a
|
||||
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
|
||||
# commands discharge or zero, neither of which can create offtake, while
|
||||
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
|
||||
# therefore bound by this floor. That is §9.1's "money outranks the
|
||||
# maintenance schedule", now as a consequence of the priorities.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
|
||||
# legacy single-entity path: there is no telegram stream, so
|
||||
# `p1.averager` has no open block, so the guard would be BLIND on every
|
||||
# cycle forever - and blind means "no charging". That is not a
|
||||
# conservative default, it is a controller that silently stops charging
|
||||
# on every install that has not opted in to P1 yet; test_clamp.py's
|
||||
# export case caught it doing exactly that. The billed quantity simply
|
||||
# is not measured on that path, and the honest report is NOT COVERED -
|
||||
# the same answer, for the same reason, that the status page already
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
dev_top = self.dev_max if self.dev_max is not None else max_w
|
||||
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
|
||||
self.peak.project(self.p1.averager, now),
|
||||
batt_w=self.batt,
|
||||
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||
reading=self.peak.peak(now),
|
||||
achievable_w=min(max_w, abs(float(dev_top))),
|
||||
# ⚠️ 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,
|
||||
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
|
||||
standing, max_w, self.freeze_wd.cfg),
|
||||
)
|
||||
peak_claim = (peakguard.claim(self.peak_guard)
|
||||
if self.peak_guard is not None else None)
|
||||
if peak_claim is not None:
|
||||
claims.append(peak_claim)
|
||||
# ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a
|
||||
# guard that appended one every 15 s for a whole quarter would push the
|
||||
# cause of the event off the end of the log that is meant to explain it.
|
||||
# SAFETY-03 logs every engagement because a clamp engagement IS the
|
||||
# count that matters there; here the count is the seconds, and the
|
||||
# engagement's own reason line already carries them.
|
||||
engaged_now = self.peak_guard is not None and self.peak_guard.engaged
|
||||
if engaged_now != self._peak_engaged:
|
||||
self._peak_engaged = engaged_now
|
||||
if self.peak_guard is not None:
|
||||
self.log_event(self.peak_guard.explain())
|
||||
|
||||
# --- 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 -----------------------------------------------------
|
||||
if result.owns_setpoint:
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
@@ -383,9 +645,44 @@ class Controller:
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
|
||||
self.log_event("peak at risk - maintenance charge shaping suspended")
|
||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
|
||||
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
|
||||
# above the winner's priority, and most restrictive wins - so a peak
|
||||
# event during a maintenance charge window files `lo > hi`, which is
|
||||
# a CONTRADICTION and resolves to 0 W. The battery would park in the
|
||||
# exact quarter it should be shaving. Priority does not save it:
|
||||
# clause 2 has no notion of a higher-priority limit overriding a
|
||||
# lower one, and it should not - "most restrictive" is what makes the
|
||||
# safety bounds unconditional.
|
||||
#
|
||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||
# than out-voting it, which is what this branch already did for
|
||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||
# default install, so until now the suspension could not fire on the
|
||||
# site that needed it. `self.peak_guard` is measured from the meter
|
||||
# and is always there.
|
||||
#
|
||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
|
||||
# tidiness. It was inline, and a reviewer mutated it back to its
|
||||
# historical broken form - re-gated on `peak_forecast_entity` alone -
|
||||
# and every suite in this repository stayed green while a default
|
||||
# install parked at 0 W in the quarter that sets the month's bill.
|
||||
# Read shaping_suspended() before touching this: both terms are
|
||||
# there, and why neither is sufficient alone.
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500))):
|
||||
# Edge-logged: level-logging here would write one line per cycle
|
||||
# for as long as the event lasts and flush the 40-line action log
|
||||
# of the very context that explains it.
|
||||
if not self._maint_shape_suspended:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
@@ -500,6 +797,29 @@ class Controller:
|
||||
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||
await self.check_meter_frozen()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
await self.check_peak_saturated()
|
||||
|
||||
# --- 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 -----------------------------
|
||||
# Banking a quarter is bookkeeping about a moment that has already
|
||||
# happened; a slow SD card must never sit between the meter and the
|
||||
# setpoint. Its position is unconstrained in the way SAFETY-03's and
|
||||
# SAFETY-18's are not - it grades neither `target` nor `standing` and
|
||||
# reads only `self.p1`, which no line in this method writes.
|
||||
self.peak.sync(self.p1)
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
||||
|
||||
@@ -583,6 +903,123 @@ class Controller:
|
||||
f"for was itself out of range."),
|
||||
})
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
||||
|
||||
⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
|
||||
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal
|
||||
- clamp it, then alarm" is SAFETY-03's rule and it is right there: the
|
||||
loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping
|
||||
it silently is what let it keep climbing behind a dashboard full of legal
|
||||
numbers. A peak floor above the inverter's rating is not a defect. It is
|
||||
a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W
|
||||
against a 5000 W unit - and alarming on the ordinary case is how an
|
||||
operator learns to dismiss the notification that matters. So an
|
||||
engagement is an ordinary control action: it goes to the §15 action log
|
||||
and the status page, and no further.
|
||||
|
||||
What DOES earn the operator's attention is saturation: the guard asked
|
||||
for more discharge than the inverter can give, so the quarter is going
|
||||
over the ceiling anyway and the controller has already done everything
|
||||
available to it. That is the one state where the money is going to be
|
||||
spent and a human is the only remaining actuator.
|
||||
|
||||
Edge-triggered, and de-asserting arms it again - the same shape as
|
||||
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
|
||||
per episode, not one per cycle.
|
||||
"""
|
||||
g = self.peak_guard
|
||||
saturated = bool(g is not None and g.saturated)
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
self.log_event("PEAK GUARD SATURATED - " + g.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-07 - %s. The guard is asking for everything the inverter "
|
||||
"has and the quarter is still projected over the ceiling: this "
|
||||
"quarter will set or raise the month's billed capacity peak. The "
|
||||
"controller is STILL RUNNING and still shaving; there is nothing "
|
||||
"further it can do. Shed load.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_saturated",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of inverter",
|
||||
"message": (
|
||||
f"{g.reason}.\n\nThe battery is being commanded to everything "
|
||||
f"it can give and the quarter is STILL projected over the "
|
||||
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
|
||||
f"the month's billed capaciteitstarief peak. This is not a "
|
||||
f"controller fault - the load is simply larger than the "
|
||||
f"inverter. Shedding load in the next few minutes is the only "
|
||||
f"thing that changes the bill."),
|
||||
})
|
||||
|
||||
async def check_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:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -623,6 +1060,57 @@ class Controller:
|
||||
})
|
||||
|
||||
# -- 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:
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
if entity:
|
||||
@@ -837,6 +1325,68 @@ class Controller:
|
||||
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
|
||||
f"while the command is over "
|
||||
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
|
||||
|
||||
# TARIFF-01. The month's billed peak, and - the load-bearing half -
|
||||
# a DIFFERENT row when the store could not answer. "no peak yet" is
|
||||
# a normal state on the 1st; "unreadable" is a broken instrument, and
|
||||
# a status page that spelled them the same way would let a 0 W read
|
||||
# look like a quiet month right up until SAFETY-07 shaved nothing.
|
||||
_pk = self.peak.peak()
|
||||
if _pk.is_unreadable:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "capacity-tariff peak: " + _pk.explain()})
|
||||
else:
|
||||
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||
# a second entity - see the constructor on not doubling up.
|
||||
_frac = self.peak.warning_frac() or 0.0
|
||||
_ok = _frac < 0.8
|
||||
out.append({"ok": _ok, "warn": not _ok,
|
||||
"text": _pk.explain()
|
||||
+ f" - {_frac * 100:.0f} % of the "
|
||||
f"{self.peak.cap_w:.0f} W soft limit"
|
||||
+ f"; {self.peak.recorded} quarter(s) banked"
|
||||
+ f"; on start: {self.peak.restore_state}"})
|
||||
|
||||
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||
# are opposite states and a page that spelled them the same way
|
||||
# would show a green line over a guard that is switched off. `None`
|
||||
# is a fourth state on purpose: before the first cycle the guard has
|
||||
# concluded nothing, and saying "idle" then would be a claim about a
|
||||
# decision that has not been taken.
|
||||
_pg = self.peak_guard
|
||||
if _pg is None:
|
||||
out.append({"ok": True, "warn": True,
|
||||
"text": "peak guard: no decision yet - the first "
|
||||
"control cycle has not run"})
|
||||
elif _pg.saturated:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "PEAK GUARD SATURATED - " + _pg.reason})
|
||||
elif _pg.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:
|
||||
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
||||
else:
|
||||
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
|
||||
"text": _pg.explain()})
|
||||
else:
|
||||
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
||||
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
|
||||
# quarter-hour average is measured from P1 telegrams and this path
|
||||
# has none - and a row that only rendered where the guard runs would
|
||||
# leave the one install that is unprotected showing nothing at all.
|
||||
# Same answer, same reason, as the freeze detector's NOT COVERED row
|
||||
# a few lines up.
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"peak guard: NOT COVERED on meter_source "
|
||||
f"{o.get('meter_source')} - the quarter-hour "
|
||||
f"average is measured from P1 telegrams and this "
|
||||
f"path has none, so a runaway cannot be capped"})
|
||||
|
||||
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
||||
("battery power", self.batt, o.get("batt_entity"))]
|
||||
if not self.p1_enabled:
|
||||
@@ -944,6 +1494,12 @@ class Controller:
|
||||
("Maintenance phase", self.maint.phase),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# 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 {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
@@ -1071,6 +1627,19 @@ async def amain() -> None:
|
||||
|
||||
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
|
||||
# off the control loop: telegrams arrive every ~5 s and the loop would
|
||||
# decimate them, so the 15-minute average - the capacity-tariff billing
|
||||
|
||||
@@ -286,6 +286,26 @@ class QuarterAverager:
|
||||
"""Offtake watt-seconds accumulated in the open block so far."""
|
||||
return self._acc
|
||||
|
||||
@property
|
||||
def held_net_w(self) -> float:
|
||||
"""The signed net power currently being held forward, in W.
|
||||
|
||||
PUBLIC BECAUSE SAFETY-07 DEPENDS ON IT, AND THAT IS THE WHOLE REASON.
|
||||
`PeakTracker.project()` extrapolates the rest of the quarter from this
|
||||
value and used to reach it as `getattr(averager, "_last_net", 0.0)`. A
|
||||
default on a private name fails QUIET and fails in the DISARMING
|
||||
direction: rename `_last_net` and every projection silently credits 0 W
|
||||
held, which reads LOW, which puts the peak guard under the ceiling
|
||||
exactly when the meter says it is over. Filed as TEST-13 item 4 and
|
||||
fixed here rather than inherited - a money guard may not rest on a read
|
||||
that can go wrong without a symptom.
|
||||
|
||||
Positive is offtake, matching `P1Sample.net_w`. It is the raw held
|
||||
value, NOT floored at zero: the flooring is the accumulator's rule (only
|
||||
offtake is billed) and belongs where it is applied, not here.
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def offtake_avg_w(self) -> float:
|
||||
"""Average offtake over the part of the open block seen so far."""
|
||||
@@ -353,6 +373,54 @@ class QuarterAverager:
|
||||
self._last_pp = sample.per_phase_w
|
||||
return closed
|
||||
|
||||
# -- surviving a restart (TARIFF-01) ------------------------------------
|
||||
def snapshot(self) -> dict:
|
||||
"""The open block's whole state, as JSON-safe primitives.
|
||||
|
||||
⚠️ `last_t` is the HELD SAMPLE'S OWN timestamp and is stored as it is,
|
||||
not refreshed to "now". That is what makes a restart integrate the gap
|
||||
under exactly the `max_hold_s` rule a running process would have used:
|
||||
a short restart is a plateau, a long one leaves uncovered seconds that
|
||||
drag the closed block's average down. Rewriting it to the restore time
|
||||
would silently credit the whole outage at the last value seen before it.
|
||||
"""
|
||||
return {
|
||||
"block": self._block,
|
||||
"acc": self._acc,
|
||||
"pp_acc": list(self._pp_acc),
|
||||
"elapsed": self._elapsed,
|
||||
"last_t": self._last_t,
|
||||
"last_net": self._last_net,
|
||||
"last_pp": list(self._last_pp) if self._last_pp is not None else None,
|
||||
"phases": self.phases,
|
||||
}
|
||||
|
||||
def restore(self, state: dict) -> bool:
|
||||
"""Put a `snapshot()` back. False if it does not fit this averager.
|
||||
|
||||
A phase-count change is a reconfiguration, not a resumption: the
|
||||
per-phase accumulator would be reshaped mid-block and the two halves
|
||||
would not be the same measurement. Refusing is the honest answer, and
|
||||
the caller reports starting fresh rather than pretending it resumed.
|
||||
"""
|
||||
try:
|
||||
if int(state.get("phases", self.phases)) != self.phases:
|
||||
return False
|
||||
pp_acc = list(state.get("pp_acc") or [])
|
||||
if len(pp_acc) != self.phases:
|
||||
return False
|
||||
self._block = None if state["block"] is None else int(state["block"])
|
||||
self._acc = float(state["acc"])
|
||||
self._pp_acc = [float(v) for v in pp_acc]
|
||||
self._elapsed = float(state["elapsed"])
|
||||
self._last_t = None if state["last_t"] is None else float(state["last_t"])
|
||||
self._last_net = float(state["last_net"])
|
||||
lp = state.get("last_pp")
|
||||
self._last_pp = tuple(float(v) for v in lp) if lp is not None else None
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# what the rest of the add-on talks to
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
|
||||
add-on that is allowed to spend the loop's freedom to save money.
|
||||
|
||||
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
|
||||
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
|
||||
calendar month - and banks it where a restart cannot reach. It files no claim and
|
||||
commands nothing, and says so in its own first paragraph. This module is the
|
||||
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
|
||||
else: no writes, no I/O, no clock, no state between calls. Given a projection and
|
||||
two measured numbers it returns a decision; `main.py` files it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE HAZARD, WITH THE EURO FIGURE
|
||||
|
||||
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
|
||||
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
|
||||
no averaging and no forgiveness - the month's single worst quarter is the bill.
|
||||
|
||||
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
|
||||
itself: a meter that answers 200 OK forever with a document that never changes,
|
||||
which every freshness mechanism in this stack reads as healthy while the loop
|
||||
integrates a constant error to the rail and holds it there. freeze.py's own
|
||||
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
|
||||
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
|
||||
a 900 s billing quarter. That is about nine minutes of human reaction time and
|
||||
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
|
||||
|
||||
This is the cap. It is deliberately NOT a second freeze detector: it consumes
|
||||
freeze.py's shipped predicate rather than transcribing it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
|
||||
|
||||
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
|
||||
the projected quarter average approaches the soft limit; hard clamp on charging
|
||||
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
|
||||
by the peak guard; only charging and import are."
|
||||
|
||||
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
|
||||
shape that is:
|
||||
|
||||
* incapable of capping discharge - a floor never stops the loop discharging
|
||||
HARDER, so 9.1's asymmetry is structural here rather than remembered;
|
||||
* incapable of capping export, for the same reason;
|
||||
* a hard clamp on charging whenever the floor is above the standing charge;
|
||||
* pre-emptive discharge whenever the floor is above zero.
|
||||
|
||||
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
|
||||
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
|
||||
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
|
||||
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
|
||||
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
|
||||
not dragged off zero by this floor. That is the correct order - a controller
|
||||
pushing power the wrong way is wrong however much money it is trying to save.
|
||||
|
||||
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
|
||||
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
|
||||
is exactly what makes the safety bounds unconditional and must not be changed.
|
||||
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
|
||||
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
|
||||
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
|
||||
quarter of the month it should be shaving hardest. The rule for two limits that
|
||||
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
|
||||
shaping for as long as this guard is engaged, which is the same thing that branch
|
||||
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
|
||||
maintenance schedule" reads as a suppression there rather than as a priority
|
||||
here. The floor's clamp to `achievable_w` closes the only other pair that could
|
||||
cross - the P_SAFETY rating bounds.
|
||||
|
||||
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
|
||||
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
|
||||
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
|
||||
with no edit to this file. Expressing the priority as a claim rather than as an
|
||||
if/else in main.py is the whole reason that holds.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
|
||||
|
||||
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
|
||||
the seconds left in the open block. `project()` gives
|
||||
|
||||
projected = (acc + held * remaining) / 900
|
||||
|
||||
so the held level that would land the quarter exactly on the ceiling is
|
||||
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
|
||||
off the meter are the difference between the two:
|
||||
|
||||
excess_w = 900 * (projected - ceiling) / remaining
|
||||
|
||||
That single expression carries the whole control law, and two properties of it
|
||||
are the argument for this design:
|
||||
|
||||
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
|
||||
`excess` is what must come off the METER, so it falls to zero the moment the
|
||||
battery is already taking it - and a guard that disengaged there would hand
|
||||
the actuator back, watch the offtake spring straight to the raw load, and
|
||||
engage again next cycle. That is not a hypothetical: it is what the first
|
||||
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
|
||||
engage/release cycles in 900 s, and the quarter still finished 49 W over the
|
||||
limit it had spent a quarter of an hour holding. 10.2 names the cost
|
||||
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
|
||||
wear and - per 9.1 - for the peak it is trying to avoid."
|
||||
|
||||
`batt_w + excess` IS the quantity to threshold: the offtake there would be
|
||||
with the battery at zero, less the level this quarter can still afford. Read
|
||||
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
|
||||
is filed for as long as a positive floor is needed and decays continuously
|
||||
to nothing as the load falls. So there is still no hysteresis constant in
|
||||
this file - not because oscillation was ruled out by argument, but because
|
||||
the thresholded quantity is the one that does not oscillate.
|
||||
|
||||
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
|
||||
GROWS. This is the answer to the degeneracy at the top of a block, where the
|
||||
projection is an extrapolation from almost no evidence: the guard is at its
|
||||
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
|
||||
once most of the quarter is behind it and the projection is nearly a
|
||||
measurement. The formula supplies that; no minimum-evidence threshold was
|
||||
invented, because inventing one would delay the guard in the case it exists
|
||||
for - a sustained rail from second zero.
|
||||
|
||||
The floor itself is then
|
||||
|
||||
floor_w = batt_w + excess_w
|
||||
|
||||
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
|
||||
available. The measured one is right for two independent reasons. First, 9.1's
|
||||
settled constraint is that the utility bills what FLOWED - the arithmetic
|
||||
`grid = load - batt` is true of delivered power and false of intent. Second, and
|
||||
concretely: when the inverter cannot follow (the saturation case control.py
|
||||
already freezes the integrator for), a floor built on the command asks for
|
||||
`command + excess` every cycle while the command never arrives, and winds itself
|
||||
to the rail; a floor built on the measurement asks for `delivered + excess`,
|
||||
which stops rising the moment delivery stops rising. The delivered form cannot
|
||||
wind up. The intent form can.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
|
||||
|
||||
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
|
||||
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
|
||||
because the money is spent on `max(month's peak so far, this quarter)`:
|
||||
|
||||
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||
|
||||
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||
that has already been paid for is what stops one bad quarter from parking the
|
||||
battery's charging for three weeks.
|
||||
|
||||
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
|
||||
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
|
||||
a peak store that cannot be read makes this guard STRICTER and never looser. That
|
||||
direction is the whole point: tariff.py's `PeakReading` exists because a database
|
||||
that did not answer must not become a number, and the number it would have become
|
||||
here - a zero peak - is exactly the one that reads as "no peak recorded, full
|
||||
freedom". It is also why this module does NOT consume `headroom_w()`, whose
|
||||
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
|
||||
headroom, and full headroom is a guard that is not there.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
|
||||
|
||||
A projection is evidence only if the quantity under it was actually measured.
|
||||
Three ways it is not, all of them checked BEFORE the projection is graded,
|
||||
because grading a stimulus where the quantity it grades is degenerate is how a
|
||||
guard passes its own tests while being switched off:
|
||||
|
||||
* NO OPEN BLOCK. `project()` answers a fresh averager with
|
||||
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
|
||||
compares as comfortably under any ceiling. That is not "the quarter is fine",
|
||||
it is "there is no quarter". Graded as a number it silently disarms the guard
|
||||
on exactly the startup path where nothing else is watching.
|
||||
|
||||
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
|
||||
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
|
||||
meter's last value forward is how a fabricated peak gets onto a permanent
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
samples keep ARRIVING, so coverage stays perfect and the projection is
|
||||
confident - and if the stuck value is an export, the projection is a
|
||||
confident ZERO while the real meter runs at the rail. This is the case
|
||||
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
|
||||
|
||||
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
|
||||
quantity is unobservable. It is the minimal action that closes the hazard, and it
|
||||
is one-directional - forbidding charging can only ever lower delivered offtake,
|
||||
never raise it - so a false blindness costs a charge the site wanted and cannot
|
||||
cost energy the pack did not want to give. Contrast the graded branch, which can
|
||||
force discharge; blindness deliberately does not earn that, because there is by
|
||||
definition no measurement to size it from.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
|
||||
|
||||
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
|
||||
false trip parks the battery; a false alarm costs an alert." That reasoning is
|
||||
right for a DETECTOR whose negative case had to be established on a rig run. It
|
||||
does not carry to this guard, and the two committed captures say so:
|
||||
|
||||
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
|
||||
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
|
||||
quarters comes near engaging. test_peakguard.py re-measures that every run
|
||||
from the committed capture rather than quoting it here.
|
||||
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
|
||||
projection every cycle and releases the instant the projection drops back
|
||||
under the ceiling. There is no state to clear and no operator to wake. A
|
||||
false engagement costs one cycle of discharge, not a parked battery - which
|
||||
is precisely the cost SAFETY-18 could not bound and this one can.
|
||||
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
|
||||
it, so the worst thing a bug in this file can do is fail to relax a floor -
|
||||
and a floor is bounded above by `achievable_w`, which main.py takes from the
|
||||
same `max_w` and device rating the arbiter already enforces.
|
||||
|
||||
So it trips. It is also the only guard here whose trigger IS the billed quantity
|
||||
itself, measured from parsed P1 telegrams, rather than a proxy for it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SATURATION IS REPORTED, NOT ALARMED AS A BUG
|
||||
|
||||
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
|
||||
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
|
||||
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
|
||||
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
|
||||
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
|
||||
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
|
||||
notification on that - not because the controller is broken, but because it is
|
||||
the one case where the money WILL be spent and the controller has already done
|
||||
everything it can. An engagement that is not saturated is an ordinary control
|
||||
action and goes to the action log, like the clamp's engagements do.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
|
||||
|
||||
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
|
||||
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
|
||||
already owns; `achievable_w` off `max_w` and the device rating the arbiter
|
||||
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
|
||||
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
||||
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
|
||||
mismatch trap 14's own warning box describes.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
|
||||
|
||||
SAFETY-07 left this file saying, in its own words, that the graded branch can
|
||||
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
|
||||
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
|
||||
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
|
||||
quarter is over the ceiling. That is the behaviour working as designed, and it
|
||||
is unbounded from below by anything in the EMS.
|
||||
|
||||
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
|
||||
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
|
||||
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
|
||||
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
|
||||
|
||||
The sum/max/net reduction is an open product question with the owner. TARIFF-01
|
||||
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
|
||||
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
|
||||
function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .arbiter import P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
|
||||
# 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"
|
||||
|
||||
# ⚠️ 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)
|
||||
class PeakDecision:
|
||||
"""What the guard concluded this cycle, with everything it concluded it from.
|
||||
|
||||
Every field is derived from a measurement taken in the same cycle. There is
|
||||
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
|
||||
reason: an accessor that turns "not measured" into a number re-creates the
|
||||
defect the type exists to remove.
|
||||
"""
|
||||
|
||||
engaged: bool
|
||||
floor_w: float | None # the `lo` the claim carries; None = no claim
|
||||
ceiling_w: float
|
||||
ceiling_from: str
|
||||
blind: str | None = None
|
||||
projected_w: float | None = None
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
saturated: bool = False
|
||||
# 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 = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.floor_limited:
|
||||
return self.reason
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
return (f"peak guard idle - quarter projects "
|
||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||
f"ceiling ({self.ceiling_from})")
|
||||
return self.reason
|
||||
|
||||
|
||||
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
"""The average this quarter may not exceed, and where the number came from.
|
||||
|
||||
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
|
||||
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
|
||||
return the cap, which is the tighter answer. A failed read therefore makes
|
||||
the guard stricter, never looser - see the module note.
|
||||
"""
|
||||
cap = float(cap_w)
|
||||
if (reading is not None and getattr(reading, "is_observed", False)
|
||||
and reading.peak_w is not None and float(reading.peak_w) > cap):
|
||||
return float(reading.peak_w), CEILING_MONTH_PEAK
|
||||
return cap, CEILING_CAP
|
||||
|
||||
|
||||
def excess_w(projection, ceiling_w) -> float:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
The whole control law, and the only place it is written down:
|
||||
|
||||
excess = 900 * (projected - ceiling) / remaining
|
||||
|
||||
Positive means the quarter is projected over; zero or negative means it is
|
||||
not. `remaining <= 0` is not a large number, it is an undefined one - a
|
||||
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
|
||||
than dividing.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if remaining <= 0.0:
|
||||
return 0.0
|
||||
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
|
||||
|
||||
|
||||
def uncovered_s(projection) -> float:
|
||||
"""Seconds of the open block the meter did not actually cover.
|
||||
|
||||
Derived from what `Projection` already carries rather than re-measured:
|
||||
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
|
||||
"""
|
||||
return max(0.0, float(projection.elapsed_s)
|
||||
* (1.0 - float(projection.covered_frac)))
|
||||
|
||||
|
||||
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
"""Why this projection may not be graded, or None if it may be.
|
||||
|
||||
Evaluated BEFORE the projection is compared to anything. Each of the three is
|
||||
a way for the projected number to be confident and wrong, and the first - a
|
||||
fresh averager's all-zero `Projection` - is confident, wrong AND under every
|
||||
ceiling there is.
|
||||
"""
|
||||
if frozen:
|
||||
return BLIND_FROZEN
|
||||
if projection.block_start is None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return None
|
||||
|
||||
|
||||
def 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:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||
sensor for it is missing - and then there is nothing to size a
|
||||
floor from, so no floor is invented.
|
||||
`achievable_w` the largest discharge the arbiter could actually grant
|
||||
(`min(max_w, device max)`). The floor is clamped to it so this
|
||||
claim can NEVER produce `lo > hi` in the resolver - a
|
||||
contradiction there resolves to 0 W, which would park the
|
||||
battery in the exact minute it should be discharging hardest.
|
||||
`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)
|
||||
ceil_top = abs(float(achievable_w))
|
||||
|
||||
blind = blindness(projection, max_hold_s, frozen)
|
||||
if blind is not None:
|
||||
# 0.0, not a sized floor. There is no trustworthy measurement to size one
|
||||
# from; the claim that remains is "do not add to the offtake you cannot
|
||||
# see", which is one-directional and cannot cost pack energy.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
|
||||
# 0 W. Filing nothing is the honest answer: a floor sized from a
|
||||
# measurement that is not there is a made-up number.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="no battery measurement to size a floor from")
|
||||
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||
# A block with no seconds left cannot be influenced. This is degenerate
|
||||
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
|
||||
# the battery's own current output would be a claim that says nothing.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="the quarter has no seconds left to influence")
|
||||
|
||||
ex = excess_w(projection, ceiling)
|
||||
wanted = float(batt_w) + ex
|
||||
if wanted <= 0.0:
|
||||
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||
# `excess` is the watts that must come off the METER, so it goes to zero
|
||||
# as soon as the battery is already removing them - and a guard that
|
||||
# disengaged there handed the actuator straight back, the offtake sprang
|
||||
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
|
||||
# billed quarter that bang-bang cycled 17 times in 900 s and left the
|
||||
# quarter 49 W over the limit it had just spent a quarter of an hour
|
||||
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
|
||||
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
|
||||
#
|
||||
# `batt_w + excess` is the offtake that would exist with the battery at
|
||||
# zero, minus the level the quarter can afford. Testing THAT is the same
|
||||
# arithmetic read as a level rather than as a switch: the claim is filed
|
||||
# for as long as a positive floor is needed and decays continuously to
|
||||
# nothing as the load falls, so there is still no hysteresis constant -
|
||||
# the quantity being thresholded is simply the right one.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
# 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)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
|
||||
that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate
|
||||
transcribed into its own test is one copy plus a future disagreement about
|
||||
what the check means, and the copy that drifts is always the grading one.
|
||||
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
|
||||
back to its historical broken form and all twelve suites stayed green.
|
||||
|
||||
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
|
||||
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
|
||||
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
|
||||
with no notion of one limit outranking another - which is precisely what
|
||||
makes the safety bounds unconditional and must not be changed. Two crossing
|
||||
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
|
||||
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
|
||||
P_MAINT_SHAPE 40 does not save it and never could.
|
||||
|
||||
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
|
||||
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
|
||||
there and `peak_at_risk()` answers False forever. Before this card the
|
||||
suspension therefore could not fire on the very site that needed it. The
|
||||
second term is the MEASURED guard, which is always present wherever P1 is.
|
||||
Deleting either term - or re-gating the whole thing on the forecast - parks
|
||||
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
|
||||
part 7 drives the real cycle with each of those mutations in place.
|
||||
"""
|
||||
if peak_at_risk(peak_forecast_w, float(cap_w)):
|
||||
return True
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim, or None when the guard is idle.
|
||||
|
||||
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
|
||||
export are never capped by the peak guard" expressed as a fact about the
|
||||
object rather than as a rule somebody has to remember.
|
||||
"""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
return None
|
||||
# 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),
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
else "quarter peak"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,702 @@
|
||||
"""TARIFF-01 - the monthly capacity-tariff peak, and the record that outlives it.
|
||||
|
||||
WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. This module is an INSTRUMENT. It
|
||||
measures the number the Belgian capaciteitstarief bills on - the highest
|
||||
15-minute average offtake of the calendar month (Fluvius / Resa / Ores) - and it
|
||||
writes that number down somewhere a restart cannot reach. It makes no claim,
|
||||
applies no clamp, and commands nothing. The guard that will read it is SAFETY-07,
|
||||
and it belongs in `arbiter.py` as a `P_PEAK` claim when it is built. Keeping the
|
||||
measurement and the actuation apart is the whole reason this file is small.
|
||||
|
||||
WHAT WAS ALREADY BUILT, AND WHY THIS CARD IS NOT 8 SP OF NEW ARITHMETIC.
|
||||
`p1.QuarterAverager` already computes AC 1 in full: clock-aligned 900 s blocks,
|
||||
time-weighted over irregular telegram spacing, offtake-only, per-phase, with a
|
||||
`max_hold_s` gap rule. It was unwired and unpersisted - `P1Ingest.blocks` is a
|
||||
96-entry list in memory that a restart erases. So this file is the persistence
|
||||
and the monthly reduction, not a second accumulator. Adding one would be adding
|
||||
a second source of truth for the exact quantity the money depends on.
|
||||
|
||||
BLOCKS, NOT A SLIDING WINDOW. The Description says "rolling"; AC 1 says
|
||||
"15-min block average". They are different numbers and the utility bills the
|
||||
second one. §9.1 calls the billed unit the "highest 15-minute average offtake",
|
||||
and §9.1's own worked case - "one 4998 W quarter moved a monthly peak from
|
||||
4832 -> 5013 W" - is stated per QUARTER, an object a sliding window does not
|
||||
have. "Rolling" here means CONTINUOUSLY UPDATED (the open block's partial
|
||||
average is readable at every sample), not a trailing-15-minutes window. A
|
||||
trailing window would report a peak the meter never bills, and it reports it
|
||||
HIGHER than the real one: a load burst straddling :15 fills a trailing window
|
||||
completely while contributing to two block averages at partial weight. Guarding
|
||||
that would spend battery defending an interval nobody charges for.
|
||||
|
||||
DELIVERED POWER, NOT INTENDED POWER. SAFETY-20 has an open question about
|
||||
whether guards should grade `Decision.target` (pre-clamp intent) or `standing`
|
||||
(what actually reached the wire). For a capacity tariff there is no question to
|
||||
ask: the utility's meter integrates the current that flowed. This module never
|
||||
sees a setpoint at all - it consumes `P1Sample`, which is the meter's own
|
||||
reading - and that is on purpose. A peak computed from intent would be wrong in
|
||||
the expensive direction (a clamped 9 kW request that delivered 5 kW would
|
||||
record 9 kW, and every consumer would shave against a peak that never existed).
|
||||
|
||||
AN UNREADABLE STORE IS NOT A ZERO PEAK. This is the defect this repo spent a
|
||||
day removing from `sim/riglock.py`, where `holder()` was rewritten to answer
|
||||
`observed / not_observed / gone` instead of a partial dict every caller
|
||||
`.get()`s a default out of. The same trap is here and it is worse, because the
|
||||
degenerate value is not merely wrong, it is DISARMING: a peak read as 0 W gives
|
||||
SAFETY-07 the maximum possible headroom and silently switches the guard off at
|
||||
the moment its own storage is broken. So `peak()` returns a `PeakReading` whose
|
||||
`state` is one of OBSERVED / NO_RECORD / NOT_OBSERVED, `peak_w` is `None`
|
||||
unless the state is OBSERVED, and there is no `.get(..., 0)` shaped accessor
|
||||
anywhere on it. NO_RECORD ("this month has no completed block yet", a normal
|
||||
state on the 1st) and NOT_OBSERVED ("the database did not answer") are
|
||||
different types of thing and are never spelled the same way.
|
||||
|
||||
CONFIG: NONE ADDED, ON PURPOSE. CFG-03 is open because the option defaults
|
||||
already live in three places held apart by a tripwire in `test_validate.py`. The
|
||||
soft limit this measurement is graded against already exists as `peak_cap_w`
|
||||
(config.yaml, default 3500), already validated, already read by
|
||||
`control.peak_at_risk()`. §14 names a `tariffs.capacity_peak_limit_kw: 2.5` - in
|
||||
KILOwatts, against watts everywhere else - and adding it would have made a fourth
|
||||
defaults site AND imported the unit mismatch §14's own warning box says must be
|
||||
caught at startup. So this module takes its limit as a plain constructor
|
||||
argument in WATTS and config.yaml is untouched by this ticket.
|
||||
|
||||
THE DATABASE. SQLite in /data, the add-on's only durable volume, alongside
|
||||
`store.py`'s state.json. Two tiers from §15, and their retention is the point:
|
||||
|
||||
quarter_blocks one row per completed 900 s block INDEFINITE
|
||||
monthly_peak one row per calendar month PERMANENT
|
||||
|
||||
Neither is ever pruned here, and no sweep in this file can prune them - there is
|
||||
no DELETE statement against either table anywhere in this module. §15's asymmetry
|
||||
is why: raw samples vanish after 7 days, so a 15-minute aggregate that was not
|
||||
written AS IT HAPPENED can never be recomputed. `monthly_peak` is maintained as a
|
||||
derived record - upserted on every block close, and `recompute_month()` re-derives
|
||||
it from `quarter_blocks` so the two can be PROVEN not to have diverged rather
|
||||
than assumed not to have.
|
||||
|
||||
RESTART MID-BLOCK. The open block's accumulator is memory. A restart at minute 12
|
||||
that dropped it would under-read the rest of the quarter, and SAFETY-07's clamp
|
||||
would release exactly when it was most needed. So the accumulator is checkpointed
|
||||
(`QuarterAverager.snapshot()`) into a single row, at most `CHECKPOINT_S` stale,
|
||||
and restored on startup. Three cases, all of them exercised in `test_tariff.py`:
|
||||
|
||||
* the checkpoint's block is the block containing `now` -> restore it verbatim,
|
||||
including the held sample's ORIGINAL timestamp, so the first sample after the
|
||||
restart integrates the gap under the same `max_hold_s` rule a running process
|
||||
would have applied. A restart longer than `max_hold_s` therefore leaves
|
||||
uncovered seconds, and a closed block is always divided by the full 900 s, so
|
||||
the gap DRAGS THE BILLED AVERAGE DOWN instead of being averaged away;
|
||||
* the checkpoint's block ended while we were down -> that block really happened
|
||||
and its measured part is real, so it is closed at `acc / 900` and recorded,
|
||||
then a fresh block is opened. Discarding it would lose measured energy;
|
||||
* no checkpoint, or one that cannot be read -> start clean and SAY SO. The
|
||||
return value of `restore()` names which of the three happened; it is never
|
||||
silently the first one.
|
||||
|
||||
Double counting is answered structurally rather than by care: `quarter_start` is
|
||||
the PRIMARY KEY, and the recorded average may only ever be RAISED by a later
|
||||
write (`ON CONFLICT ... WHERE excluded.offtake_avg_w > ...`). Replaying the same
|
||||
block twice is therefore a no-op, and a restart that reprocesses a block with
|
||||
LESS coverage than the first pass cannot lower a figure already banked.
|
||||
|
||||
MONTH BOUNDARY. Calendar months are LOCAL (Europe/Brussels); blocks are stored in
|
||||
UTC. In August those differ by two hours, so a block starting 2026-08-31 22:15
|
||||
UTC belongs to SEPTEMBER and putting it in August would move a month's bill.
|
||||
Block alignment itself needs no timezone - every Belgian offset is a whole number
|
||||
of hours, so the 900 s UTC grid lands on :00/:15/:30/:45 local on both sides of a
|
||||
DST transition, which is the argument `QuarterAverager` already carries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff")
|
||||
|
||||
QUARTER_S = 900
|
||||
|
||||
# How stale the partial-block checkpoint may be. This is the ONLY thing a crash
|
||||
# can lose from an open block, and the loss is bounded and one-directional: the
|
||||
# lost seconds were already integrated into `_acc`, so dropping them makes the
|
||||
# restored block read LOW, never high. At 15 s against a 900 s block the
|
||||
# worst-case under-read of one quarter's average is 15/900 = 1.7 % of that
|
||||
# quarter's own offtake - see test_tariff.py, which computes that bound from
|
||||
# these two constants rather than quoting it.
|
||||
CHECKPOINT_S = 15.0
|
||||
|
||||
# -- reading states. Three, and never fewer. -------------------------------- #
|
||||
OBSERVED = "observed" # the store answered and there is a peak
|
||||
NO_RECORD = "no_record" # the store answered and this month has none yet
|
||||
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# local time, without a hard dependency on the tz database
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _EUTimezone(tzinfo):
|
||||
"""Europe/Brussels by the EU harmonised rule, for hosts with no tzdata.
|
||||
|
||||
⚠️ THIS IS A FALLBACK AND IT IS NOT SILENT. `zoneinfo` is tried first and is
|
||||
what runs on the add-on's container. The fallback exists because the
|
||||
alternative - quietly using UTC when tzdata is missing - misfiles every block
|
||||
in the last two hours of a month into the wrong month's bill, and does it
|
||||
without a symptom. Encoding the rule (CET, CEST from 01:00 UTC on the last
|
||||
Sunday of March to 01:00 UTC on the last Sunday of October) is 20 lines and
|
||||
is checked against zoneinfo, date by date, wherever zoneinfo is available.
|
||||
"""
|
||||
|
||||
# The rule, in both frames. `fromutc` is overridden rather than inherited
|
||||
# because the base implementation calls back into `utcoffset` with a
|
||||
# datetime whose tzinfo is self - and the first version of this class
|
||||
# answered that by calling `astimezone`, which calls `utcoffset`, which
|
||||
# recursed until the stack ran out. A tzinfo must answer from the value it
|
||||
# is handed, never by converting it.
|
||||
@staticmethod
|
||||
def _last_sunday(year: int, month: int, hour: int) -> datetime:
|
||||
d = datetime(year, month + 1, 1) - timedelta(days=1)
|
||||
return (d - timedelta(days=(d.weekday() + 1) % 7)).replace(hour=hour)
|
||||
|
||||
def _dst_local(self, naive: datetime) -> bool:
|
||||
"""From a LOCAL wall clock: 02:00 CET last Sunday March -> 03:00 CEST
|
||||
last Sunday October. The one ambiguous hour each autumn resolves to
|
||||
standard time, which cannot move a month boundary."""
|
||||
y = naive.year
|
||||
return self._last_sunday(y, 3, 2) <= naive < self._last_sunday(y, 10, 3)
|
||||
|
||||
def _dst_utc(self, naive_utc: datetime) -> bool:
|
||||
"""From UTC: both EU transitions happen at 01:00 UTC, everywhere."""
|
||||
y = naive_utc.year
|
||||
return self._last_sunday(y, 3, 1) <= naive_utc < self._last_sunday(y, 10, 1)
|
||||
|
||||
def fromutc(self, dt: datetime) -> datetime:
|
||||
return dt + timedelta(hours=2 if self._dst_utc(dt.replace(tzinfo=None)) else 1)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(hours=1)
|
||||
return timedelta(hours=2 if self._dst_local(dt.replace(tzinfo=None)) else 1)
|
||||
|
||||
def dst(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(0)
|
||||
return timedelta(hours=1 if self._dst_local(dt.replace(tzinfo=None)) else 0)
|
||||
|
||||
def tzname(self, dt):
|
||||
return "CEST" if dt is not None and self._dst_local(dt.replace(tzinfo=None)) else "CET"
|
||||
|
||||
|
||||
def _billing_tz():
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo("Europe/Brussels")
|
||||
# Prove it resolves before trusting it: a ZoneInfo built from an empty
|
||||
# tzdata answers, and answering wrongly is the failure being avoided.
|
||||
if datetime(2026, 7, 1, tzinfo=timezone.utc).astimezone(tz).hour == 2:
|
||||
return tz
|
||||
_LOG.warning("zoneinfo Europe/Brussels resolved but gave the wrong "
|
||||
"summer offset - falling back to the encoded EU rule")
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
_LOG.info("no usable tz database (%s) - using the encoded EU rule", err)
|
||||
return _EUTimezone()
|
||||
|
||||
|
||||
BILLING_TZ = _billing_tz()
|
||||
|
||||
|
||||
def month_key(when: datetime) -> str:
|
||||
"""The calendar month a UTC instant is billed in, as 'YYYY-MM' LOCAL time."""
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
local = when.astimezone(BILLING_TZ)
|
||||
return f"{local.year:04d}-{local.month:02d}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the per-phase reduction policy - one function, deliberately
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §9.1 requires the average be computed per-phase-aware, but does NOT say whether
|
||||
# the BILLED figure on a three-phase connection is the sum of the phases or the
|
||||
# worst single phase. §5.2 requires the per-phase data; nothing states the
|
||||
# reduction. So it is one function with one argument, and correcting it later is
|
||||
# a one-line change plus a test parameter rather than a change to the
|
||||
# accumulator. §17's "Three-phase unbalanced load" scenario is the case that
|
||||
# tells the three apart - on a balanced load all three agree, which is exactly
|
||||
# why a balanced fixture must not be what this is tested on.
|
||||
POLICY_NET = "net" # default: the connection's net offtake, what the meter bills
|
||||
POLICY_SUM = "sum" # sum of the per-phase offtakes (differs when a phase exports)
|
||||
POLICY_MAX = "max" # the worst single phase
|
||||
|
||||
|
||||
def billed_w(block, policy: str = POLICY_NET) -> float:
|
||||
"""Reduce one closed block to the single figure the month is billed on."""
|
||||
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||
if policy == POLICY_NET or not pp:
|
||||
return float(block.offtake_avg_w)
|
||||
if policy == POLICY_SUM:
|
||||
return float(sum(pp))
|
||||
if policy == POLICY_MAX:
|
||||
return float(max(pp))
|
||||
raise ValueError(f"unknown per-phase reduction policy: {policy!r}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# what a read of the store answers
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PeakReading:
|
||||
"""An answer about a month's peak, or an honest refusal to give one.
|
||||
|
||||
⚠️ There is no `peak_w_or(default)` and there must never be one. Every
|
||||
accessor that lets a caller turn "the database did not answer" into a number
|
||||
re-creates the defect this type exists to remove. Callers ask `is_observed`
|
||||
first, and a caller that forgets gets `None` and a TypeError, which is loud.
|
||||
"""
|
||||
|
||||
state: str
|
||||
month: str
|
||||
peak_w: float | None = None
|
||||
quarter_start: datetime | None = None
|
||||
per_phase_w: tuple | None = None
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def is_observed(self) -> bool:
|
||||
return self.state == OBSERVED
|
||||
|
||||
@property
|
||||
def is_unreadable(self) -> bool:
|
||||
return self.state == NOT_OBSERVED
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.state == OBSERVED:
|
||||
at = self.quarter_start.isoformat() if self.quarter_start else "?"
|
||||
return f"{self.month}: peak {self.peak_w:.0f} W, set by the quarter at {at}"
|
||||
if self.state == NO_RECORD:
|
||||
return f"{self.month}: no completed quarter recorded yet"
|
||||
return (f"{self.month}: PEAK UNREADABLE ({self.detail}) - this is NOT a "
|
||||
f"zero peak and must not be used as headroom")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the store
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS quarter_blocks (
|
||||
quarter_start TEXT PRIMARY KEY, -- ISO-8601 UTC, aligned to 900 s
|
||||
month TEXT NOT NULL, -- 'YYYY-MM', LOCAL calendar month
|
||||
offtake_avg_w REAL NOT NULL,
|
||||
per_phase_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_quarter_month ON quarter_blocks(month);
|
||||
CREATE TABLE IF NOT EXISTS monthly_peak (
|
||||
month TEXT PRIMARY KEY,
|
||||
peak_w REAL NOT NULL,
|
||||
quarter_start TEXT NOT NULL,
|
||||
per_phase_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS partial_block (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state_json TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class PeakStore:
|
||||
"""SQLite behind /data. Every read answers with a state, never with a default."""
|
||||
|
||||
def __init__(self, path: str = "/data/tariff.db"):
|
||||
self.path = path
|
||||
self.ok = False
|
||||
self.last_error: str | None = None
|
||||
self._db: sqlite3.Connection | None = None
|
||||
self._open()
|
||||
|
||||
def _open(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
# ⚠️ THE DIRECTORY IS NOT CREATED. /data is mounted by the Supervisor and
|
||||
# always exists on a real install, so `makedirs` here could only ever
|
||||
# succeed where the durable volume is MISSING - manufacturing a database
|
||||
# on the container's ephemeral layer that looks durable, reads fine, and
|
||||
# is thrown away on the next restart. That is a peak store that silently
|
||||
# forgets, which is worse than one that says it cannot answer.
|
||||
if parent and not os.path.isdir(parent):
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong. A store that OPENED and then broke is
|
||||
# the error case, and it is logged as one below.
|
||||
_LOG.info("no peak store: %s - the monthly peak will read "
|
||||
"NOT_OBSERVED", self.last_error)
|
||||
return
|
||||
try:
|
||||
self._db = sqlite3.connect(self.path, timeout=5.0,
|
||||
check_same_thread=False)
|
||||
# WAL: a power cut mid-write must not leave a torn database that
|
||||
# reads as "no peak this month" - the exact shape of the defect.
|
||||
self._db.execute("PRAGMA journal_mode=WAL")
|
||||
self._db.executescript(_SCHEMA)
|
||||
self._db.commit()
|
||||
self.ok = True
|
||||
self.last_error = None
|
||||
except sqlite3.Error as err:
|
||||
self._db = None
|
||||
self.ok = False
|
||||
self.last_error = str(err)
|
||||
# ⚠️ Deliberately NOT raised. §6's fail-open rule: an aggregation
|
||||
# fault must not stop a controller that is regulating a battery. The
|
||||
# cost of continuing is a peak that reads NOT_OBSERVED, and every
|
||||
# reader is built to treat that as "do not use me", not as 0 W.
|
||||
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
|
||||
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._db is not None:
|
||||
try:
|
||||
self._db.close()
|
||||
finally:
|
||||
self._db = None
|
||||
self.ok = False
|
||||
|
||||
# -- writing ------------------------------------------------------------
|
||||
def record_block(self, block, policy: str = POLICY_NET) -> bool:
|
||||
"""Persist one completed block and fold it into its month's peak.
|
||||
|
||||
Both tables move in ONE transaction. A crash between them would leave a
|
||||
banked quarter that the monthly peak does not know about, and the peak is
|
||||
the record the money is read off.
|
||||
"""
|
||||
if self._db is None:
|
||||
return False
|
||||
w = billed_w(block, policy)
|
||||
start = block.start.astimezone(timezone.utc).isoformat()
|
||||
month = month_key(block.start)
|
||||
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||
pp_json = json.dumps(list(pp)) if pp else None
|
||||
try:
|
||||
with self._db:
|
||||
# A block may only ever be RAISED. A restart that reprocesses a
|
||||
# quarter it had already banked will have LESS coverage than the
|
||||
# first pass (its first seconds are gone), so an unconditional
|
||||
# REPLACE would silently lower a recorded figure.
|
||||
self._db.execute(
|
||||
"INSERT INTO quarter_blocks(quarter_start, month, offtake_avg_w, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(quarter_start) DO UPDATE SET"
|
||||
" offtake_avg_w=excluded.offtake_avg_w,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||
(start, month, w, pp_json))
|
||||
self._db.execute(
|
||||
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(month) DO UPDATE SET"
|
||||
" peak_w=excluded.peak_w,"
|
||||
" quarter_start=excluded.quarter_start,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w",
|
||||
(month, w, start, pp_json))
|
||||
return True
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
_LOG.error("could not record quarter %s (%s)", start, err)
|
||||
return False
|
||||
|
||||
# -- reading ------------------------------------------------------------
|
||||
def peak(self, when: datetime | None = None, month: str | None = None) -> PeakReading:
|
||||
"""The billed peak for a month. Three possible answers, never a default."""
|
||||
if month is None:
|
||||
month = month_key(when or datetime.now(timezone.utc))
|
||||
if self._db is None:
|
||||
return PeakReading(NOT_OBSERVED, month,
|
||||
detail=self.last_error or "store never opened")
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT peak_w, quarter_start, per_phase_json FROM monthly_peak"
|
||||
" WHERE month=?", (month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||
if row is None:
|
||||
return PeakReading(NO_RECORD, month)
|
||||
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def recompute_month(self, month: str) -> PeakReading:
|
||||
"""Re-derive the peak from `quarter_blocks` - the divergence check.
|
||||
|
||||
`monthly_peak` is a derived record, so it can be PROVEN equal to the
|
||||
tier it derives from instead of trusted to be. Read-only.
|
||||
"""
|
||||
if self._db is None:
|
||||
return PeakReading(NOT_OBSERVED, month,
|
||||
detail=self.last_error or "store never opened")
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT offtake_avg_w, quarter_start, per_phase_json FROM quarter_blocks"
|
||||
" WHERE month=? ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1",
|
||||
(month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||
if row is None:
|
||||
return PeakReading(NO_RECORD, month)
|
||||
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def blocks_in(self, month: str) -> list:
|
||||
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
|
||||
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
|
||||
counter reports the sample size it actually got."""
|
||||
if self._db is None:
|
||||
return []
|
||||
try:
|
||||
return [(datetime.fromisoformat(a), float(b)) for a, b in self._db.execute(
|
||||
"SELECT quarter_start, offtake_avg_w FROM quarter_blocks"
|
||||
" WHERE month=? ORDER BY quarter_start", (month,))]
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return []
|
||||
|
||||
# -- the open block's checkpoint ----------------------------------------
|
||||
def save_partial(self, state: dict) -> bool:
|
||||
if self._db is None:
|
||||
return False
|
||||
try:
|
||||
with self._db:
|
||||
self._db.execute(
|
||||
"INSERT INTO partial_block(id, state_json) VALUES(1,?)"
|
||||
" ON CONFLICT(id) DO UPDATE SET state_json=excluded.state_json",
|
||||
(json.dumps(state),))
|
||||
return True
|
||||
except (sqlite3.Error, TypeError, ValueError) as err:
|
||||
self.last_error = str(err)
|
||||
return False
|
||||
|
||||
def load_partial(self) -> dict | None:
|
||||
if self._db is None:
|
||||
return None
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT state_json FROM partial_block WHERE id=1").fetchone()
|
||||
return json.loads(row[0]) if row else None
|
||||
except (sqlite3.Error, ValueError) as err:
|
||||
self.last_error = str(err)
|
||||
return None
|
||||
|
||||
def clear_partial(self) -> None:
|
||||
# The one DELETE in this module, and it is against the checkpoint - a
|
||||
# scratch row, not a retention tier. Neither quarter_blocks nor
|
||||
# monthly_peak is deleted from anywhere in this file.
|
||||
if self._db is None:
|
||||
return
|
||||
try:
|
||||
with self._db:
|
||||
self._db.execute("DELETE FROM partial_block WHERE id=1")
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the tracker: what main.py holds
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Projection:
|
||||
"""The open block's end-of-quarter estimate, with the reasons to distrust it.
|
||||
|
||||
`elapsed_s` travels WITH the number rather than beside it, because early in a
|
||||
quarter a projection is a 10-second extrapolation over 900 s and a consumer
|
||||
that cannot see that will react to noise. `covered_frac` is separately useful:
|
||||
it is below 1.0 when the meter had a gap, which is the "unreliable quarter"
|
||||
the functional note asks be marked rather than reported low.
|
||||
"""
|
||||
|
||||
projected_w: float
|
||||
elapsed_w: float
|
||||
elapsed_s: float
|
||||
covered_frac: float
|
||||
complete: bool
|
||||
block_start: datetime | None
|
||||
held_w: float
|
||||
|
||||
def explain(self) -> str:
|
||||
return (f"quarter projects {self.projected_w:.0f} W "
|
||||
f"({self.elapsed_s:.0f} s elapsed, {self.covered_frac * 100:.0f} % covered"
|
||||
+ ("" if self.complete else ", INCOMPLETE") + ")")
|
||||
|
||||
|
||||
class PeakTracker:
|
||||
"""Drives the store from `P1Ingest`. Measures; never commands."""
|
||||
|
||||
def __init__(self, store: PeakStore, cap_w: float = 3500.0,
|
||||
policy: str = POLICY_NET, checkpoint_s: float = CHECKPOINT_S):
|
||||
self.store = store
|
||||
self.cap_w = float(cap_w)
|
||||
self.policy = policy
|
||||
self.checkpoint_s = float(checkpoint_s)
|
||||
self.recorded = 0
|
||||
self.restore_state = "not attempted"
|
||||
self._seen: set = set()
|
||||
self._last_ckpt = 0.0
|
||||
|
||||
# -- startup ------------------------------------------------------------
|
||||
def restore(self, averager, now: datetime | None = None) -> str:
|
||||
"""Put the open block back. Returns which of the three cases happened."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
snap = self.store.load_partial()
|
||||
if not snap:
|
||||
self.restore_state = ("fresh: no checkpoint" if self.store.ok
|
||||
else "fresh: store unreadable")
|
||||
return self.restore_state
|
||||
block = snap.get("block")
|
||||
if block is None:
|
||||
self.restore_state = "fresh: checkpoint had no open block"
|
||||
return self.restore_state
|
||||
|
||||
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
|
||||
if block == current:
|
||||
if not averager.restore(snap):
|
||||
# A reshaped accumulator (meter_phases changed) is a different
|
||||
# measurement, not a resumable one. Say so; do not half-resume.
|
||||
self.store.clear_partial()
|
||||
self.restore_state = "fresh: checkpoint does not fit this averager"
|
||||
return self.restore_state
|
||||
self.restore_state = (
|
||||
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
|
||||
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
|
||||
return self.restore_state
|
||||
|
||||
# The block ended while the add-on was down. Its measured part really
|
||||
# happened, so bank it at acc/900 rather than discard measured energy.
|
||||
# It reads LOW because the seconds after the shutdown were never covered,
|
||||
# and that is the honest figure: coverage a closed block did not get.
|
||||
closed = _ClosedBlock(
|
||||
start=datetime.fromtimestamp(block, timezone.utc),
|
||||
offtake_avg_w=float(snap.get("acc", 0.0)) / QUARTER_S,
|
||||
per_phase_offtake_avg_w=(tuple(v / QUARTER_S for v in snap["pp_acc"])
|
||||
if snap.get("pp_acc") and snap.get("last_pp") else None))
|
||||
banked = self.store.record_block(closed, self.policy)
|
||||
self.store.clear_partial()
|
||||
self.restore_state = (
|
||||
f"closed a stale block from {closed.start.isoformat()} at "
|
||||
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
|
||||
+ ("banked" if banked else "NOT banked - store unwritable"))
|
||||
return self.restore_state
|
||||
|
||||
# -- steady state -------------------------------------------------------
|
||||
def sync(self, ingest, now_mono: float | None = None) -> int:
|
||||
"""Bank any blocks that closed, and refresh the checkpoint. Returns the
|
||||
number of blocks newly recorded on this call."""
|
||||
import time as _t
|
||||
n = 0
|
||||
for block in getattr(ingest, "blocks", ()):
|
||||
key = block.start.timestamp()
|
||||
if key in self._seen:
|
||||
continue
|
||||
if self.store.record_block(block, self.policy):
|
||||
self._seen.add(key)
|
||||
self.recorded += 1
|
||||
n += 1
|
||||
if n:
|
||||
# A block just closed, so the checkpoint's block is gone: clearing it
|
||||
# in the same breath is what stops a crash here from replaying it.
|
||||
self.store.clear_partial()
|
||||
self._last_ckpt = 0.0
|
||||
self.checkpoint(ingest, now_mono or _t.monotonic())
|
||||
return n
|
||||
|
||||
def checkpoint(self, ingest, now_mono: float, force: bool = False) -> bool:
|
||||
if not force and (now_mono - self._last_ckpt) < self.checkpoint_s:
|
||||
return False
|
||||
avg = getattr(ingest, "averager", None)
|
||||
if avg is None:
|
||||
return False
|
||||
self._last_ckpt = now_mono
|
||||
return self.store.save_partial(avg.snapshot())
|
||||
|
||||
# -- reading ------------------------------------------------------------
|
||||
def peak(self, when: datetime | None = None) -> PeakReading:
|
||||
return self.store.peak(when)
|
||||
|
||||
def headroom_w(self, when: datetime | None = None) -> float | None:
|
||||
"""Watts left under the soft limit, or None when it cannot be known.
|
||||
|
||||
⚠️ `None`, not `self.cap_w`. An unreadable peak returning full headroom
|
||||
is the disarming default this whole module is shaped against.
|
||||
"""
|
||||
r = self.peak(when)
|
||||
if r.state == NO_RECORD:
|
||||
return self.cap_w
|
||||
if not r.is_observed:
|
||||
return None
|
||||
return max(0.0, self.cap_w - r.peak_w)
|
||||
|
||||
def warning_frac(self, when: datetime | None = None) -> float | None:
|
||||
"""Peak as a fraction of the soft limit - §9.1's 80 % warning entity."""
|
||||
r = self.peak(when)
|
||||
if r.state == NO_RECORD:
|
||||
return 0.0
|
||||
if not r.is_observed or self.cap_w <= 0:
|
||||
return None
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
credits the held value over what remains. At `elapsed_s == 900` and full
|
||||
coverage they are equal - which is precisely why nothing may be tested
|
||||
only at that point.
|
||||
"""
|
||||
start = averager.block_start
|
||||
if start is None:
|
||||
# ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is
|
||||
# "there is no quarter". `block_start is None` is the flag that says
|
||||
# which, and SAFETY-07 grades that flag BEFORE it grades the number -
|
||||
# see peakguard.blindness(). A consumer that reads only
|
||||
# `projected_w` here reads 0 W, which is under every ceiling there
|
||||
# is, on exactly the startup path where nothing else is watching.
|
||||
return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
# fallback, so renaming the attribute would have kept every projection
|
||||
# green while crediting 0 W held - a projection that reads LOW, which is
|
||||
# the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4.
|
||||
# `held_net_w` is now a documented public property on QuarterAverager and
|
||||
# is read without a default, so the same rename raises AttributeError on
|
||||
# the first cycle instead of quietly disarming the money guard.
|
||||
held = max(averager.held_net_w, 0.0)
|
||||
return Projection(
|
||||
projected_w=(acc + held * remaining) / QUARTER_S,
|
||||
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
|
||||
elapsed_s=elapsed_wall,
|
||||
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
|
||||
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
|
||||
block_start=start,
|
||||
# Carried so a consumer can see WHAT was extrapolated, not only the
|
||||
# extrapolation. It is the signed held value; `projected_w` uses the
|
||||
# offtake-only form above, and the two differ whenever the site is
|
||||
# exporting - which is the healthy operating point here.
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClosedBlock:
|
||||
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||
`record_block` has one input type; kept private so nothing else makes one."""
|
||||
|
||||
start: datetime
|
||||
offtake_avg_w: float
|
||||
per_phase_offtake_avg_w: tuple | None
|
||||
@@ -116,6 +116,40 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
|
||||
It is warned about below, not refused.
|
||||
|
||||
7. A price configuration that cannot mean what it says. [(a)]
|
||||
Three cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box);
|
||||
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
||||
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
||||
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
||||
documented offline fallback into a permanent no-price-data condition
|
||||
reached by accident. Same shape as 1, 4 and 5 every time - a source the
|
||||
operator chose, overridden by a layer they cannot see.
|
||||
|
||||
⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4
|
||||
makes no-price-data a supported state, and §9.2 says a large share of the
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
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:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -136,11 +170,19 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||
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
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
@@ -349,6 +391,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
|
||||
f"or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
# --- 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 -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||
@@ -382,6 +457,214 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
return problems
|
||||
|
||||
|
||||
def _price_problems(opts: dict) -> list[str]:
|
||||
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
||||
|
||||
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
||||
will be: §11.4 makes no-price-data a first-class supported state (static
|
||||
profile, provisional plan, no arbitrage), and an add-on that will not start
|
||||
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices), and every refusal is still case (a) from this module's
|
||||
docstring: a source the operator SET being overridden by a layer they cannot
|
||||
see. The additions are `static_profile` alongside the Home Assistant price
|
||||
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
||||
and a `static_profile` that does not tile the day, which would leave hours
|
||||
with no price at all.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
||||
|
||||
if pid != "off" and pid not in PROVIDERS:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
||||
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
|
||||
"Refused rather than defaulted, per the §14 warning box - a "
|
||||
"silent fall back to 'off' would look exactly like a working "
|
||||
"provider that happens never to find a cheap hour.")
|
||||
return problems # nothing below can be judged against an unknown id
|
||||
|
||||
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
||||
# price_now_entity/price_avg_entity are the original delegation of pricing
|
||||
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
|
||||
# the maintenance cheap-window charge floor. They are KEPT - retiring them
|
||||
# would take a live install's only price signal away - but they may not run
|
||||
# alongside another price source, because main.py reads exactly one and the
|
||||
# loser is silent. That is (a), and it is the "silently double up" the PM
|
||||
# named on 2026-08-30.
|
||||
#
|
||||
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
|
||||
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
|
||||
# "would decide nothing", which was true only because nothing consumed the
|
||||
# series; test_price.py section 12 carried a tripwire that went red the
|
||||
# moment a consumer landed, with instructions to fix the wording here in the
|
||||
# same commit. It landed, it went red, and this is that fix. What is true
|
||||
# NOW, stated exactly and no wider:
|
||||
#
|
||||
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
|
||||
# provider series into a TariffPlan, a cheap-window flag and an
|
||||
# arbitrage verdict, so the seam is no longer inert.
|
||||
# * main.py reads the STATIC side of that module every cycle
|
||||
# (static_status()), which makes static_profile a real writer of `cheap`.
|
||||
# * main.py does NOT call resolve_tariff() on the control tick, and that
|
||||
# is still deliberate: a provider fetch is network I/O and the tick is
|
||||
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
|
||||
# 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 three refusals below rather than two, and none of them claims more
|
||||
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
|
||||
# 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")
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if pid != "off" and ha_price:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
||||
"reads, and read_inputs() never looks at the other source once "
|
||||
"they are set. The provider series is consumed - "
|
||||
"app/static_tariff.py builds a tariff plan and an arbitrage "
|
||||
"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 "
|
||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||
"Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
||||
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
||||
# from the Home Assistant pair when it is set and from the static profile
|
||||
# otherwise - one flag, first writer wins, and the operator who filled in a
|
||||
# day/night tariff has no way to see that it lost.
|
||||
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||
problems.append(
|
||||
f"static_profile is set AND {' and '.join(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
||||
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
||||
"ENTITIES WIN: main.py reads them first and never looks at the "
|
||||
"static profile, so the day/night tariff you typed would decide "
|
||||
"nothing and nothing would say so. Clear price_now_entity and "
|
||||
"price_avg_entity to use static_profile, or clear static_profile "
|
||||
"to keep the Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
||||
# that does not tile the day has hours with no price, and the only ways to
|
||||
# serve those are to invent a number or to fail at the moment the clock
|
||||
# reaches them. §14's warning box - refuse to start on a mismatch rather
|
||||
# than clamp quietly - is the same instruction one level down.
|
||||
raw_profile = opts.get("static_profile", "")
|
||||
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
|
||||
try:
|
||||
profile = parse_static_profile(raw_profile)
|
||||
except ProfileInvalid as e:
|
||||
profile = None
|
||||
problems.append(f"static_profile cannot be used: {e}")
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
"static_profile carries one distinct price (%.4f EUR/kWh "
|
||||
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
||||
"off by arithmetic - a flat series has no spread - and the "
|
||||
"add-on runs self-consumption only, which is §9.2's handling "
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
# is a real gap worth naming - but refusing to start over it would break
|
||||
# every install TARIFF-02 shipped, none of which could have had one.
|
||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||
_LOG.warning(
|
||||
"price_provider is %r but static_profile is empty. §11.4's "
|
||||
"degradation row is 'no price data -> fall back to the static "
|
||||
"profile'; with none configured, a feed outage leaves this site "
|
||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||
"but filling it in is what makes that row work.", pid)
|
||||
|
||||
# ⚠️ 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():
|
||||
problems.append(
|
||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
||||
"the offline fallback has no file to read. Every fetch would "
|
||||
"return no-price-data with a filesystem error as its reason - "
|
||||
"which is §11.4 behaviour arrived at by accident rather than by "
|
||||
"configuration. Point it at the CSV, or set price_provider to "
|
||||
"'off', which says the same thing on purpose.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -162,9 +162,147 @@ options:
|
||||
# --- tariff / capacity tariff (all optional) ------------------------------
|
||||
peak_forecast_entity: ""
|
||||
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
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
|
||||
# because that has already happened three times.
|
||||
#
|
||||
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
||||
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
||||
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
||||
# and manual_csv publish a figure that already carries the markup, so setting
|
||||
# it under those is ignored - and logged as ignored, never applied twice.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
#
|
||||
# A JSON object, on one line, whose periods must tile every day type exactly
|
||||
# once - no gap and no overlap. A gap is REFUSED at startup rather than
|
||||
# defaulted, because an invented price is one an optimiser would trade on.
|
||||
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
|
||||
# add-on derives "fixed" from there being one distinct price, so there is no
|
||||
# box to tick and none to tick wrongly.
|
||||
#
|
||||
# {"periods": [
|
||||
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
||||
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
||||
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
||||
#
|
||||
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
|
||||
# a period applies on the day the CLOCK reads, so the weekend row above is
|
||||
# required, not decorative. See DOCS.md.
|
||||
#
|
||||
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
|
||||
# dynamic providers are assembled onto. price_markup_eur_kwh and
|
||||
# price_taxes_eur_kwh are NOT added to it.
|
||||
#
|
||||
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
|
||||
# data -> fall back to the static profile"; with this empty there is nothing
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# --- 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 ------------------------------------------------------------------
|
||||
estop_fitted: false
|
||||
log_level: info
|
||||
@@ -236,8 +374,47 @@ schema:
|
||||
|
||||
peak_forecast_entity: str?
|
||||
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_avg_entity: str?
|
||||
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# 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
|
||||
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())
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
# 30 days of P1 net power — 2026-08-01 … 2026-08-30
|
||||
|
||||
`sensor.p1_meter_active_power` out of Home Assistant's recorder, one gzipped
|
||||
JSONL file per **local** day (Europe/Brussels), same schema as the two files one
|
||||
directory up:
|
||||
|
||||
{"t": "2026-08-22T22:00:00+00:00", "w": -25.0}
|
||||
|
||||
**485,841 samples, 30/30 days read, 0 days failed, 4.13 MB gzipped.**
|
||||
Produced by `tools/export_p1_history.py`; `manifest.json` here carries the
|
||||
per-day counts, sha256 of every file, and every gap.
|
||||
|
||||
python3 tools/export_p1_history.py --days 30 --end-date 2026-08-30 \
|
||||
--out-dir scenarios/p1-net-30d-2026-08
|
||||
|
||||
## Why this exists
|
||||
|
||||
[[TARIFF-01]] AC 3 and [[SAFETY-07]] AC 3 both ask for *"monthly peak held under
|
||||
the soft limit in >= 95 % of intervals over a 30-day simulation replay"*, and
|
||||
both were merged with the criterion honestly unmet because only two days of
|
||||
capture existed. `test_tariff.py`'s docstring still recorded the reason as
|
||||
"nothing longer was ever captured" — which was true about the past and was
|
||||
quietly read as a statement about the future. It was not one. Probed on
|
||||
2026-08-31, HA's recorder answered a 20-minute window with 230 samples at 30
|
||||
days back and 0 at 45, so the whole month was still there and was exported the
|
||||
same day. **The window rolls: 2026-08-01 leaves the recorder around 2026-09-15.**
|
||||
|
||||
## The size decision — COMMITTED, deliberately, at 4.13 MB
|
||||
|
||||
Three options were on the table. This one was taken and the cost is stated
|
||||
rather than absorbed, and it is measured rather than estimated - a
|
||||
`git bundle` of the whole history before and after this commit:
|
||||
|
||||
release/1.0 @ 86c43eb 1.58 MB
|
||||
DATA-01 5.28 MB +3.70 MB, x3.3
|
||||
|
||||
A full clone of this repository therefore more than triples, and a Home
|
||||
Assistant supervisor clones it on every add-on install and update. That is the
|
||||
price; the paragraphs below are why it was judged worth paying rather than
|
||||
avoided by making the data worse or making it disappear.
|
||||
|
||||
* **Downsampling is not available.** It is not a matter of taste: the shipped
|
||||
`meter_max_age_s` is **30 s**, which is the age past which the controller's
|
||||
own ingest stops counting a held reading as coverage. Any resample coarser
|
||||
than 30 s therefore converts real coverage into modelled meter-gap and changes
|
||||
the very number AC 3 measures. Resampling at or under 30 s saves almost
|
||||
nothing against a 5 s cadence.
|
||||
* **"Outside the repo with a pointer" is what already happened, and it is why
|
||||
this card exists.** The two committed days came from the infra repo's private
|
||||
`sim/scenarios/`, the one-off export script was thrown away, and three cards
|
||||
then deferred an acceptance criterion on the belief that the data could not be
|
||||
produced again. `test_tariff.py` states the general rule verbatim: *a
|
||||
regression fixture CI cannot see is not a regression fixture.* CI's token is
|
||||
minted for this repository only.
|
||||
* **So: committed** — but in a SUBDIRECTORY, which is load-bearing. Both
|
||||
`test_tariff.py` part 9 and `test_peakguard.py` part 6 do
|
||||
`(HERE / "scenarios").glob("p1-net-*.jsonl.gz")`, which does not recurse. Drop
|
||||
these thirty files beside the two and CI grows sixty full-day
|
||||
replays - about 50 s of pure compute - on every PR forever, `test_tariff.py`'s
|
||||
`check(... replay_days < 30)` starts failing, and both files' honesty notes
|
||||
become false. In `p1-net-30d-2026-08/` they are visible to CI, invisible to
|
||||
the globs, and the two committed days keep their job as the small fixtures the
|
||||
suites grade against.
|
||||
|
||||
They are also written once and never edited, so git stores one already-deflated
|
||||
blob per day with no delta churn.
|
||||
|
||||
## Gaps are visible, never interpolated
|
||||
|
||||
Nothing is filled, carried forward or resampled. A hole in the recorder is a
|
||||
hole here — consecutive `t` values further apart than the cadence — which is
|
||||
exactly what the controller's freshness rule already reads as *no coverage*, so
|
||||
a missing hour replays as a missing hour rather than as an hour of whatever the
|
||||
last sample said.
|
||||
|
||||
**13 gaps over 30 s, 1291 s in total: 99.950 % of the month is covered.**
|
||||
All of them are listed in `manifest.json` and by
|
||||
`python3 tools/replay_ac3.py <dir> --report-gaps`. The longest is 263 s on
|
||||
2026-08-11.
|
||||
|
||||
Three samples in the month carried the state `unavailable` rather than a number
|
||||
(2026-08-18 02:03:54Z and 04:10:41Z, 2026-08-25 01:52:02Z). There is no float to
|
||||
write and the committed schema has no slot for a non-number, so they are absent
|
||||
from the series and recorded individually in `manifest.json` under
|
||||
`non_numeric_states`. Each one sits inside a gap that is already listed, which
|
||||
is the honest rendering: the meter was not reporting a power then.
|
||||
|
||||
**A failed read is never an empty hour.** If a day's read fails, the exporter
|
||||
writes NO FILE, records `"read": "FAILED"` with the error, and exits non-zero —
|
||||
so a missing day is unreadable-and-known rather than silently zero. The entity
|
||||
is also proved to exist through `/api/states` before any day is trusted, because
|
||||
a typo would otherwise answer `200 []` thirty times and produce a beautifully
|
||||
consistent dataset of nothing. On this run: 30 read, 0 failed, 0 empty.
|
||||
|
||||
## AC 3, measured
|
||||
|
||||
cd goodwe_controller
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
485841 samples, 2879 closed quarters (30.0 days' worth)
|
||||
month 2026-08: peak 5029.8 W uncontrolled -> 4107.3 W guarded
|
||||
|
||||
TARIFF-01 AC 3 (UNCONTROLLED): 2847/2879 = 98.89 % of quarters at or under 3500 W
|
||||
SAFETY-07 AC 3 (GUARDED): 2850/2879 = 98.99 %
|
||||
guard cycles 143810, engaged on the measurement 1542, blind 195,
|
||||
saturated 0, modelled discharge 8060 Wh
|
||||
|
||||
2879 rather than 2880 quarters: the last quarter of 2026-08-30 closes only when
|
||||
a telegram past its boundary arrives, and that telegram is 2026-08-31's.
|
||||
|
||||
### What the numbers actually say — three findings, not a pass
|
||||
|
||||
1. **AC 3 is met on the ">= 95 % of intervals" reading, and the criterion turns
|
||||
out to be nearly insensitive.** The UNCONTROLLED month — no controller, no
|
||||
peak shaving, nothing running — already scores 98.89 %. A criterion that a
|
||||
house passes by doing nothing does not discriminate between a working guard
|
||||
and an absent one. That is a finding about the acceptance criterion, and it
|
||||
is reported rather than filed as a win.
|
||||
2. **On the other reading of the same sentence, AC 3 is NOT met.** "Monthly peak
|
||||
held under the soft limit" taken as *the peak itself* fails: the guard pulls
|
||||
August's peak from **5029.8 W to 4107.3 W** (-922.5 W, -18.3 %), which is
|
||||
real money on a Belgian capacity tariff, but 4107.3 W is still over the
|
||||
3500 W soft limit. Both readings are printed by the tool; neither is hidden.
|
||||
3. **The guard stops defending the soft limit once the month's peak is set, and
|
||||
that is by design.** `quarter_ceiling_w` uses `CEILING_MONTH_PEAK`: the
|
||||
billed quantity is the month's maximum, so a quarter under an
|
||||
already-recorded peak costs nothing extra and the guard does not spend the
|
||||
battery on it. The month's 32 over-cap quarters fall on four nights
|
||||
(2026-08-17, -18, -20, -21); **31 of them are pulled down** — 7494 W of
|
||||
quarter-average removed in total, 8060 Wh of modelled discharge — but only
|
||||
**3 land under 3500 W**, because by then the ceiling being defended is the
|
||||
running month peak (~4107 W), not the soft limit. The single untouched
|
||||
quarter, 2026-08-18T08:15Z at 4039.7 W, is the same rule seen from the other
|
||||
side: the running peak was already 4833 W, so 4039.7 W added nothing to the
|
||||
bill and the guard correctly stayed out. AC 3's wording asks the guard for
|
||||
something its own tariff logic deliberately does not optimise.
|
||||
|
||||
Also worth an eye: **195 blind engagements** over 143,810 cycles — the guard
|
||||
acting on a projection it could not fully cover. On the two committed days that
|
||||
count was 5. It scales with the gaps above.
|
||||
|
||||
## Verification of the exporter
|
||||
|
||||
`tools/export_p1_history.py --verify` re-fetches a committed day by the exact
|
||||
route used here and diffs it against the file:
|
||||
|
||||
python3 tools/export_p1_history.py \
|
||||
--verify scenarios/p1-net-2026-08-20.jsonl.gz \
|
||||
scenarios/p1-net-2026-08-23.jsonl.gz
|
||||
|
||||
Both days come back **byte-identical after decompression** — 865,024 B /
|
||||
15,600 samples and 894,387 B / 16,351 samples — and the copies of those two days
|
||||
inside this directory are byte-identical to the committed ones too. The gzip
|
||||
CONTAINERS differ by design: a gzip header carries an mtime and a stored
|
||||
filename, and this exporter writes mtime 0 so its own output is reproducible,
|
||||
where the 2026-08-25 one-off wrote a wall-clock mtime. Content equality is the
|
||||
claim; container equality would only assert that two files were written at the
|
||||
same second.
|
||||
|
||||
`tools/replay_ac3.py` is checked the same way rather than trusted: pointed at
|
||||
`scenarios/` it must reproduce `test_peakguard.py` part 6's published totals,
|
||||
and `--expect` makes it a hard check.
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
|
||||
reproduces 189/190 quarters, the 5029.8 W billed quarter, the 3485.3 W guarded
|
||||
result and the 878 Wh of modelled discharge that part 6 prints.
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"generated": "2026-08-31T08:42:40.614548+00:00",
|
||||
"generator": "tools/export_p1_history.py",
|
||||
"source": "http://192.168.2.158:8123/api/history/period",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"timezone": "Europe/Brussels",
|
||||
"gap_threshold_s": 30.0,
|
||||
"format": "{\"t\": <ISO-8601 UTC>, \"w\": <float watts>} per line, gzip",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"window": [
|
||||
"2026-08-01T00:00:00+02:00",
|
||||
"2026-08-02T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1128060,
|
||||
"attempts": 1,
|
||||
"samples": 16511,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-07-31T22:00:00+00:00",
|
||||
"last": "2026-08-01T21:59:56.268499+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.3,
|
||||
"mean_cadence_s": 5.233,
|
||||
"file": "p1-net-2026-08-01.jsonl.gz",
|
||||
"bytes_gz": 140531,
|
||||
"bytes_raw": 913203,
|
||||
"sha256_raw": "120de2a0b7eb552281dac4bc1f8fd76d947ba72d1f5b426d9dcdd85ec103bd06",
|
||||
"sha256_gz": "e09bdee349dd3668a073b7fd77974e639c269ec37e73e81a9706cadde083f064"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"window": [
|
||||
"2026-08-02T00:00:00+02:00",
|
||||
"2026-08-03T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1115387,
|
||||
"attempts": 1,
|
||||
"samples": 16303,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-01T22:00:00+00:00",
|
||||
"last": "2026-08-02T21:59:55.191360+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.2,
|
||||
"mean_cadence_s": 5.3,
|
||||
"file": "p1-net-2026-08-02.jsonl.gz",
|
||||
"bytes_gz": 141404,
|
||||
"bytes_raw": 903234,
|
||||
"sha256_raw": "9ab4891c0abf9d621fa528848fc3a874687843804768e8aeab6a88f7bb04b679",
|
||||
"sha256_gz": "94774139b3e19d04bcda3ac5ea988dd4d645e99004f90b917c6b0bf3fbac21cb"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"window": [
|
||||
"2026-08-03T00:00:00+02:00",
|
||||
"2026-08-04T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1123668,
|
||||
"attempts": 1,
|
||||
"samples": 16424,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-02T22:00:00+00:00",
|
||||
"last": "2026-08-03T21:59:55.255808+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.3,
|
||||
"mean_cadence_s": 5.261,
|
||||
"file": "p1-net-2026-08-03.jsonl.gz",
|
||||
"bytes_gz": 144202,
|
||||
"bytes_raw": 909942,
|
||||
"sha256_raw": "13f8dd904a31b5b67fcfde04f6afc49c34c3a9504617278d79d87516fcd6441f",
|
||||
"sha256_gz": "e1bc0cb0e271cbc8d896aee97aa0f5c18e49e78905d1aaf137904a768047c7bc"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"window": [
|
||||
"2026-08-04T00:00:00+02:00",
|
||||
"2026-08-05T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1108582,
|
||||
"attempts": 1,
|
||||
"samples": 16286,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-03T22:00:00+00:00",
|
||||
"last": "2026-08-04T21:59:58.729138+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.7,
|
||||
"mean_cadence_s": 5.305,
|
||||
"file": "p1-net-2026-08-04.jsonl.gz",
|
||||
"bytes_gz": 136505,
|
||||
"bytes_raw": 896650,
|
||||
"sha256_raw": "a65cd9d1ceda61e5a40043edb401c2154feea2b36bd3221710df808d778eabce",
|
||||
"sha256_gz": "c19b19ac890f4caad4140cb27d2fe6f3e972545f664127e5d9f457e711ad8c88"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-05",
|
||||
"window": [
|
||||
"2026-08-05T00:00:00+02:00",
|
||||
"2026-08-06T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1136556,
|
||||
"attempts": 1,
|
||||
"samples": 16655,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-04T22:00:00+00:00",
|
||||
"last": "2026-08-05T21:59:56.189152+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.2,
|
||||
"mean_cadence_s": 5.188,
|
||||
"file": "p1-net-2026-08-05.jsonl.gz",
|
||||
"bytes_gz": 142561,
|
||||
"bytes_raw": 919827,
|
||||
"sha256_raw": "dd327685c6f322bfaa49bc5bf228301e790e5a96fd7c23c487b845e44a962db4",
|
||||
"sha256_gz": "5ff7a1336c49ba38b8058820181dd8ed9c2b8db41b5a3a52412a5a3d8c515332"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"window": [
|
||||
"2026-08-06T00:00:00+02:00",
|
||||
"2026-08-07T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124419,
|
||||
"attempts": 1,
|
||||
"samples": 16412,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-05T22:00:00+00:00",
|
||||
"last": "2026-08-06T21:59:57.192711+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.2,
|
||||
"mean_cadence_s": 5.265,
|
||||
"file": "p1-net-2026-08-06.jsonl.gz",
|
||||
"bytes_gz": 142313,
|
||||
"bytes_raw": 910849,
|
||||
"sha256_raw": "7cd1fe8315e24dc6391693c49d6306b0c0cf9b6c3a478e457da921001cea930c",
|
||||
"sha256_gz": "9108f169d645b9da07d4ae310666a1365ea07275ff311cd60adad1b702edbf11"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"window": [
|
||||
"2026-08-07T00:00:00+02:00",
|
||||
"2026-08-08T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1087531,
|
||||
"attempts": 1,
|
||||
"samples": 15952,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-06T22:00:00+00:00",
|
||||
"last": "2026-08-07T21:59:58.195053+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.2,
|
||||
"mean_cadence_s": 5.416,
|
||||
"file": "p1-net-2026-08-07.jsonl.gz",
|
||||
"bytes_gz": 135979,
|
||||
"bytes_raw": 879941,
|
||||
"sha256_raw": "e0f67a9214980e2fd4893208cb402165065dadce2105ce1581c6b1d49952f39a",
|
||||
"sha256_gz": "b70b84f3743d1d84055c911bcd5589cb8f0371e8916bde741308c5bb50702c29"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-08",
|
||||
"window": [
|
||||
"2026-08-08T00:00:00+02:00",
|
||||
"2026-08-09T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1058569,
|
||||
"attempts": 1,
|
||||
"samples": 15468,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-07T22:00:00+00:00",
|
||||
"last": "2026-08-08T21:59:52.805887+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86392.8,
|
||||
"mean_cadence_s": 5.586,
|
||||
"file": "p1-net-2026-08-08.jsonl.gz",
|
||||
"bytes_gz": 134025,
|
||||
"bytes_raw": 857271,
|
||||
"sha256_raw": "0be14ca7593b4c7d68570115cc26e24f6a26ab87fe3c168a8b942b167e8d4f68",
|
||||
"sha256_gz": "d93dcc8ca3eae03af13b9ae36acd5ab1eaa1e7ee93c237d386e8ded1c61f5906"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"window": [
|
||||
"2026-08-09T00:00:00+02:00",
|
||||
"2026-08-10T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1059349,
|
||||
"attempts": 1,
|
||||
"samples": 15508,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-08T22:00:00+00:00",
|
||||
"last": "2026-08-09T21:59:58.178998+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.2,
|
||||
"mean_cadence_s": 5.572,
|
||||
"file": "p1-net-2026-08-09.jsonl.gz",
|
||||
"bytes_gz": 135697,
|
||||
"bytes_raw": 857531,
|
||||
"sha256_raw": "f4793799173c502fcee5f78e2d9b0dcc984b5d2d8bd2fc0db0cd0f8d403733b3",
|
||||
"sha256_gz": "2fea6c5c8b1452d72ad5aa3b0ae9755bb2ea08071514c08b1a3b1155ef457cf1"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"window": [
|
||||
"2026-08-10T00:00:00+02:00",
|
||||
"2026-08-11T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1066721,
|
||||
"attempts": 1,
|
||||
"samples": 15594,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-09T22:00:00+00:00",
|
||||
"last": "2026-08-10T21:59:59.170056+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86399.2,
|
||||
"mean_cadence_s": 5.541,
|
||||
"file": "p1-net-2026-08-10.jsonl.gz",
|
||||
"bytes_gz": 136682,
|
||||
"bytes_raw": 863785,
|
||||
"sha256_raw": "ff5c3fa419505127a51acc56dfc13e8437e4ec2c13a11c8d8b2d6c3e20e2410e",
|
||||
"sha256_gz": "4fd30e495f4e6d1c8501d05c23f9891ac34364d86a9c55ac825d52b2ebb31023"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"window": [
|
||||
"2026-08-11T00:00:00+02:00",
|
||||
"2026-08-12T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1056281,
|
||||
"attempts": 1,
|
||||
"samples": 15371,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-10T22:00:00+00:00",
|
||||
"last": "2026-08-11T21:59:58.608150+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-11T08:00:14.121653+00:00",
|
||||
"to": "2026-08-11T08:02:54.427007+00:00",
|
||||
"seconds": 160.305
|
||||
},
|
||||
{
|
||||
"from": "2026-08-11T17:04:18.517592+00:00",
|
||||
"to": "2026-08-11T17:08:41.759777+00:00",
|
||||
"seconds": 263.242
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86398.6,
|
||||
"mean_cadence_s": 5.621,
|
||||
"file": "p1-net-2026-08-11.jsonl.gz",
|
||||
"bytes_gz": 133711,
|
||||
"bytes_raw": 856244,
|
||||
"sha256_raw": "67c2a740019f8908405b96b3436cd953af27baead2957acc7c1dcf10b37ac94e",
|
||||
"sha256_gz": "4f8c553edc9051644bd7baa659f6f92a8b5f5ce12a0410c8b5235d1e6c3084c9"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"window": [
|
||||
"2026-08-12T00:00:00+02:00",
|
||||
"2026-08-13T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124945,
|
||||
"attempts": 1,
|
||||
"samples": 16432,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-11T22:00:00+00:00",
|
||||
"last": "2026-08-12T21:59:58.648966+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.6,
|
||||
"mean_cadence_s": 5.258,
|
||||
"file": "p1-net-2026-08-12.jsonl.gz",
|
||||
"bytes_gz": 136903,
|
||||
"bytes_raw": 911115,
|
||||
"sha256_raw": "73c8a1626640226ddffb32d564557d1cbe57a15367ea1ba3632934e15a1413dc",
|
||||
"sha256_gz": "acec87065b7de2c605db00504ec5a9b3c99584620dfb3b67194e2cf9464d6a5f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"window": [
|
||||
"2026-08-13T00:00:00+02:00",
|
||||
"2026-08-14T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1126742,
|
||||
"attempts": 1,
|
||||
"samples": 16473,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-12T22:00:00+00:00",
|
||||
"last": "2026-08-13T21:59:57.562043+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.6,
|
||||
"mean_cadence_s": 5.245,
|
||||
"file": "p1-net-2026-08-13.jsonl.gz",
|
||||
"bytes_gz": 136221,
|
||||
"bytes_raw": 912379,
|
||||
"sha256_raw": "1d9a16c529174bd8c74625a742c46d98de4acf7a045f83df8bdbac22fa86e8e7",
|
||||
"sha256_gz": "adfef36f629decf54c625f2dbfe6b4dab9441218c42557c90ac7f6d331943a59"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"window": [
|
||||
"2026-08-14T00:00:00+02:00",
|
||||
"2026-08-15T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124413,
|
||||
"attempts": 1,
|
||||
"samples": 16427,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-13T22:00:00+00:00",
|
||||
"last": "2026-08-14T21:59:55.611487+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.6,
|
||||
"mean_cadence_s": 5.26,
|
||||
"file": "p1-net-2026-08-14.jsonl.gz",
|
||||
"bytes_gz": 135570,
|
||||
"bytes_raw": 910648,
|
||||
"sha256_raw": "dc1417881e54fe215e9fcd4ed643d2482a277d61f8f27f25e2ff1e2cba29f7a8",
|
||||
"sha256_gz": "d4eff781cd16a83fd1be9e643fee5bb5d5897e7d45bb9865972a7304d92c84a4"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-15",
|
||||
"window": [
|
||||
"2026-08-15T00:00:00+02:00",
|
||||
"2026-08-16T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124461,
|
||||
"attempts": 1,
|
||||
"samples": 16516,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-14T22:00:00+00:00",
|
||||
"last": "2026-08-15T21:59:56.581991+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.6,
|
||||
"mean_cadence_s": 5.231,
|
||||
"file": "p1-net-2026-08-15.jsonl.gz",
|
||||
"bytes_gz": 137670,
|
||||
"bytes_raw": 909539,
|
||||
"sha256_raw": "f07a242faed68313512e0363d914846522ff839315ad80b55689072ac74e8a69",
|
||||
"sha256_gz": "082314c6003c2208752acc4614b9809f06735d01e216b935ea94faf578c5598e"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-16",
|
||||
"window": [
|
||||
"2026-08-16T00:00:00+02:00",
|
||||
"2026-08-17T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1127871,
|
||||
"attempts": 1,
|
||||
"samples": 16552,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-15T22:00:00+00:00",
|
||||
"last": "2026-08-16T21:59:59.527710+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86399.5,
|
||||
"mean_cadence_s": 5.22,
|
||||
"file": "p1-net-2026-08-16.jsonl.gz",
|
||||
"bytes_gz": 138347,
|
||||
"bytes_raw": 912481,
|
||||
"sha256_raw": "a44e5d961600a539ddf824d5d211ef319833666b7598e5939a6cdf2cb1e3581f",
|
||||
"sha256_gz": "8f6511e52f5be9187f422021abc93a5fa8b698b4c785877d69d15dd6de106fd8"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
"window": [
|
||||
"2026-08-17T00:00:00+02:00",
|
||||
"2026-08-18T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1133721,
|
||||
"attempts": 1,
|
||||
"samples": 16627,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-16T22:00:00+00:00",
|
||||
"last": "2026-08-17T21:59:59.685699+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-17T17:27:00.599126+00:00",
|
||||
"to": "2026-08-17T17:28:20.389628+00:00",
|
||||
"seconds": 79.791
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86399.7,
|
||||
"mean_cadence_s": 5.197,
|
||||
"file": "p1-net-2026-08-17.jsonl.gz",
|
||||
"bytes_gz": 140214,
|
||||
"bytes_raw": 917356,
|
||||
"sha256_raw": "70cce79b3732f292f7f58f0b2655bdb4a00a20a6530f1f50dac62ef5f039788b",
|
||||
"sha256_gz": "e2f9b461c4db7902e1a3d30b439ebc11ebcd9618a72d12f10e5131f38fb437bf"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"window": [
|
||||
"2026-08-18T00:00:00+02:00",
|
||||
"2026-08-19T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1074019,
|
||||
"attempts": 1,
|
||||
"samples": 15699,
|
||||
"non_numeric_states": [
|
||||
{
|
||||
"t": "2026-08-18T02:03:54.417106+00:00",
|
||||
"state": "unavailable"
|
||||
},
|
||||
{
|
||||
"t": "2026-08-18T04:10:41.786299+00:00",
|
||||
"state": "unavailable"
|
||||
}
|
||||
],
|
||||
"first": "2026-08-17T22:00:00+00:00",
|
||||
"last": "2026-08-18T21:59:54.501766+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-18T02:02:59.633845+00:00",
|
||||
"to": "2026-08-18T02:04:38.694366+00:00",
|
||||
"seconds": 99.061
|
||||
},
|
||||
{
|
||||
"from": "2026-08-18T04:09:54.292049+00:00",
|
||||
"to": "2026-08-18T04:11:38.500238+00:00",
|
||||
"seconds": 104.208
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86394.5,
|
||||
"mean_cadence_s": 5.504,
|
||||
"file": "p1-net-2026-08-18.jsonl.gz",
|
||||
"bytes_gz": 135894,
|
||||
"bytes_raw": 869570,
|
||||
"sha256_raw": "68cd44ac56099ee38f0bba4c32d7d17d33b92d28a99d27419f53c22374491ca6",
|
||||
"sha256_gz": "d6c97508f805aff040d2b4ed4e06c0e7373db2ea6227c7d79ef24c8c79074115"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-19",
|
||||
"window": [
|
||||
"2026-08-19T00:00:00+02:00",
|
||||
"2026-08-20T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 989720,
|
||||
"attempts": 1,
|
||||
"samples": 14549,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-18T22:00:00+00:00",
|
||||
"last": "2026-08-19T21:59:56.556610+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-19T01:33:29.599224+00:00",
|
||||
"to": "2026-08-19T01:34:01.653383+00:00",
|
||||
"seconds": 32.054
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.6,
|
||||
"mean_cadence_s": 5.939,
|
||||
"file": "p1-net-2026-08-19.jsonl.gz",
|
||||
"bytes_gz": 126345,
|
||||
"bytes_raw": 800369,
|
||||
"sha256_raw": "0f11ce0cee43cbd7bdb08dc48b202e58c442157ac08b2cda5b64bbab9bd56300",
|
||||
"sha256_gz": "13d61b7da903877c8ae7813b833cb5836ec04a1cf1efd0e1fa87e916bef95fd5"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"window": [
|
||||
"2026-08-20T00:00:00+02:00",
|
||||
"2026-08-21T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1068038,
|
||||
"attempts": 1,
|
||||
"samples": 15600,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-19T22:00:00+00:00",
|
||||
"last": "2026-08-20T21:59:56.742604+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-20T02:11:56.607373+00:00",
|
||||
"to": "2026-08-20T02:12:38.840458+00:00",
|
||||
"seconds": 42.233
|
||||
},
|
||||
{
|
||||
"from": "2026-08-20T20:42:04.623102+00:00",
|
||||
"to": "2026-08-20T20:43:41.666543+00:00",
|
||||
"seconds": 97.043
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.7,
|
||||
"mean_cadence_s": 5.539,
|
||||
"file": "p1-net-2026-08-20.jsonl.gz",
|
||||
"bytes_gz": 139693,
|
||||
"bytes_raw": 865024,
|
||||
"sha256_raw": "2612153963951dd13fe310ad7fbb5bec2e195db56fd99fd963134c8badae25ba",
|
||||
"sha256_gz": "362f2ee3d9d7f23f9051ba78993c5af7443dabb3b5f16339efc673444720c892"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"window": [
|
||||
"2026-08-21T00:00:00+02:00",
|
||||
"2026-08-22T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1118002,
|
||||
"attempts": 1,
|
||||
"samples": 16383,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-20T22:00:00+00:00",
|
||||
"last": "2026-08-21T21:59:57.771588+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.8,
|
||||
"mean_cadence_s": 5.274,
|
||||
"file": "p1-net-2026-08-21.jsonl.gz",
|
||||
"bytes_gz": 139105,
|
||||
"bytes_raw": 904809,
|
||||
"sha256_raw": "933d9a6899f59c128d0d9b84d6944cea63b1ad58dc57ac52e3e476f9c616f07b",
|
||||
"sha256_gz": "a563581a9a3338af173abb99a200db32881ddaf893d2458f5d8de59b5ec6041f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"window": [
|
||||
"2026-08-22T00:00:00+02:00",
|
||||
"2026-08-23T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1129094,
|
||||
"attempts": 1,
|
||||
"samples": 16520,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-21T22:00:00+00:00",
|
||||
"last": "2026-08-22T21:59:56.913786+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-21T23:05:20.035692+00:00",
|
||||
"to": "2026-08-21T23:07:51.147719+00:00",
|
||||
"seconds": 151.112
|
||||
},
|
||||
{
|
||||
"from": "2026-08-21T23:07:56.966604+00:00",
|
||||
"to": "2026-08-21T23:08:27.695101+00:00",
|
||||
"seconds": 30.728
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.23,
|
||||
"file": "p1-net-2026-08-22.jsonl.gz",
|
||||
"bytes_gz": 141564,
|
||||
"bytes_raw": 914120,
|
||||
"sha256_raw": "39ccdf67c806344490f791648822cd2cd14d73ea7cc0b6e8572a1e15e8f74641",
|
||||
"sha256_gz": "5c46f1879a723f9986d521124200cab839af2a4722b76bc97c6f6f59d0df65a2"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"window": [
|
||||
"2026-08-23T00:00:00+02:00",
|
||||
"2026-08-24T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1107164,
|
||||
"attempts": 1,
|
||||
"samples": 16351,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-22T22:00:00+00:00",
|
||||
"last": "2026-08-23T21:59:56.921115+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.284,
|
||||
"file": "p1-net-2026-08-23.jsonl.gz",
|
||||
"bytes_gz": 136277,
|
||||
"bytes_raw": 894387,
|
||||
"sha256_raw": "ffb15367bf3c98eff8a5600e4858ac99778b597c03d0a4f6154e6971b8f98a44",
|
||||
"sha256_gz": "3fa76981758a468ba0aa68b50fe68d5bdb1ef38cd7faec2b6ffdb61c9ddb2db2"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"window": [
|
||||
"2026-08-24T00:00:00+02:00",
|
||||
"2026-08-25T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1122175,
|
||||
"attempts": 1,
|
||||
"samples": 16584,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-23T22:00:00+00:00",
|
||||
"last": "2026-08-24T21:59:57.940474+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.9,
|
||||
"mean_cadence_s": 5.21,
|
||||
"file": "p1-net-2026-08-24.jsonl.gz",
|
||||
"bytes_gz": 136834,
|
||||
"bytes_raw": 906369,
|
||||
"sha256_raw": "30465d5ff51220201b4a79341e5da7bfc3d88290465b1c9d886ba94eee8dd234",
|
||||
"sha256_gz": "5103088e047bfcbd9b3f55521f431b01cd27de10c2642e00281a13faa762bdd9"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"window": [
|
||||
"2026-08-25T00:00:00+02:00",
|
||||
"2026-08-26T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1110567,
|
||||
"attempts": 1,
|
||||
"samples": 16451,
|
||||
"non_numeric_states": [
|
||||
{
|
||||
"t": "2026-08-25T01:52:02.104225+00:00",
|
||||
"state": "unavailable"
|
||||
}
|
||||
],
|
||||
"first": "2026-08-24T22:00:00+00:00",
|
||||
"last": "2026-08-25T21:59:56.883744+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-25T01:51:25.942719+00:00",
|
||||
"to": "2026-08-25T01:52:53.203963+00:00",
|
||||
"seconds": 87.261
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.252,
|
||||
"file": "p1-net-2026-08-25.jsonl.gz",
|
||||
"bytes_gz": 135890,
|
||||
"bytes_raw": 896416,
|
||||
"sha256_raw": "0e7c86b2b74f2108bc7f5f5d2de2627caaafe473ca4ae18515534dff10526e26",
|
||||
"sha256_gz": "2a5411c10d8d3b271f133c62c04bd722279b48b08d7376084fbad6cc6dd66d76"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"window": [
|
||||
"2026-08-26T00:00:00+02:00",
|
||||
"2026-08-27T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1123610,
|
||||
"attempts": 1,
|
||||
"samples": 16542,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-25T22:00:00+00:00",
|
||||
"last": "2026-08-26T21:59:58.915457+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.9,
|
||||
"mean_cadence_s": 5.223,
|
||||
"file": "p1-net-2026-08-26.jsonl.gz",
|
||||
"bytes_gz": 139301,
|
||||
"bytes_raw": 908350,
|
||||
"sha256_raw": "88d72c1b9f51707d62e744f40402de4260a04617875b85c65d2df6079c6c84ca",
|
||||
"sha256_gz": "0661588fbc0c6c42825b67d483c8818c070405c1002424b3f1d61d8fdadb6370"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"window": [
|
||||
"2026-08-27T00:00:00+02:00",
|
||||
"2026-08-28T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1122433,
|
||||
"attempts": 1,
|
||||
"samples": 16573,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-26T22:00:00+00:00",
|
||||
"last": "2026-08-27T21:59:58.538446+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-27T18:50:29.030586+00:00",
|
||||
"to": "2026-08-27T18:52:22.938470+00:00",
|
||||
"seconds": 113.908
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.214,
|
||||
"file": "p1-net-2026-08-27.jsonl.gz",
|
||||
"bytes_gz": 138009,
|
||||
"bytes_raw": 906770,
|
||||
"sha256_raw": "528ab8ee10a46275d2e91d56ef2ed847e5932fae76a1752e6f2e9828dbfa9865",
|
||||
"sha256_gz": "5cacc8c5788f43d6e6e2f569445fa82d9edc5a63b1f082f9a1d6a3bb0b404260"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"window": [
|
||||
"2026-08-28T00:00:00+02:00",
|
||||
"2026-08-29T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1114796,
|
||||
"attempts": 1,
|
||||
"samples": 16456,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-27T22:00:00+00:00",
|
||||
"last": "2026-08-28T21:59:58.515555+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.251,
|
||||
"file": "p1-net-2026-08-28.jsonl.gz",
|
||||
"bytes_gz": 135917,
|
||||
"bytes_raw": 900654,
|
||||
"sha256_raw": "f89838bc9f61af074c17817e4a6eab037318f38fa3c65dad3edfa2106abbc9fb",
|
||||
"sha256_gz": "97090b605f67dcc1f84dea2c51d4832dfe1c2af497a4f4cfe50ecb51024d9b4c"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"window": [
|
||||
"2026-08-29T00:00:00+02:00",
|
||||
"2026-08-30T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1092983,
|
||||
"attempts": 1,
|
||||
"samples": 16132,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-28T22:00:00+00:00",
|
||||
"last": "2026-08-29T21:59:57.574524+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-29T00:03:20.539844+00:00",
|
||||
"to": "2026-08-29T00:03:50.569581+00:00",
|
||||
"seconds": 30.03
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86397.6,
|
||||
"mean_cadence_s": 5.356,
|
||||
"file": "p1-net-2026-08-29.jsonl.gz",
|
||||
"bytes_gz": 134210,
|
||||
"bytes_raw": 883053,
|
||||
"sha256_raw": "896d4c9d660be4faabc5a088323e246e257877d5ff6bd0ec084b785480d76e12",
|
||||
"sha256_gz": "b6f264cd3afba63209123a27490e7e1c3ae70a86731d7ea05ae67f88917f136f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-30",
|
||||
"window": [
|
||||
"2026-08-30T00:00:00+02:00",
|
||||
"2026-08-31T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1118895,
|
||||
"attempts": 1,
|
||||
"samples": 16490,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-29T22:00:00+00:00",
|
||||
"last": "2026-08-30T21:59:58.533323+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.24,
|
||||
"file": "p1-net-2026-08-30.jsonl.gz",
|
||||
"bytes_gz": 138429,
|
||||
"bytes_raw": 904311,
|
||||
"sha256_raw": "e704818efb58bb6c505791bc9eab2e9476bf48ff8911d0e79b401fd35b529fa0",
|
||||
"sha256_gz": "9052fead2418bff3083bec3ae8ce64d6b9f6224b668f4bb510c79775f66c5b79"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"days_requested": 30,
|
||||
"days_read": 30,
|
||||
"days_failed": 0,
|
||||
"samples": 485841,
|
||||
"bytes_gz": 4126003,
|
||||
"empty_days": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -0,0 +1,861 @@
|
||||
"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
|
||||
|
||||
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
|
||||
it:
|
||||
|
||||
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
|
||||
tariff - the right window at the right local instant, on both sides of
|
||||
both 2026 DST transitions, and reaching the one shiftable load the add-on
|
||||
actually has.
|
||||
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
|
||||
AC3 The Belgian scenario suite carries a static-profile regression case.
|
||||
|
||||
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
|
||||
check needs an expected number it is computed HERE, by different arithmetic
|
||||
than app/static_tariff.py uses - the window split is re-derived by summing
|
||||
over samples with an independent local-hour test, the profile mean by a
|
||||
second pass, the saving by two routes that must agree.
|
||||
|
||||
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
|
||||
two-period tariff has this trap BUILT IN and it is the main hazard here:
|
||||
|
||||
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
|
||||
window" question and the "is it cheap" question agree under almost any
|
||||
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
|
||||
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
|
||||
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
|
||||
specifically because the mon-fri night window MUST NOT cover it.
|
||||
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
|
||||
prices "below the mean" and "is the cheapest price" are the same set, so
|
||||
a two-period profile cannot tell `cheap_now` from `price == min`.
|
||||
Section 3 therefore runs a THREE-level profile where those two answers
|
||||
differ, and asserts they differ before grading anything with it.
|
||||
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
|
||||
whole shifting question collapses. Asserted as checks in their own
|
||||
right, first, rather than assumed.
|
||||
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
|
||||
where a 24-slot implementation and a correct one agree exactly.
|
||||
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
|
||||
import fell in one window grades no split at all; section 6 asserts that
|
||||
before it reports a saving.
|
||||
|
||||
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
|
||||
price, and zero is exactly the value that makes everything look free. So
|
||||
`static_plan` with no profile is checked to produce a read with NO
|
||||
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional,
|
||||
)
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
the same."""
|
||||
try:
|
||||
fn()
|
||||
except exc:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def reason(fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return str(e)
|
||||
return ""
|
||||
|
||||
|
||||
class _Raised:
|
||||
def __init__(self, e):
|
||||
self.e = e
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||||
|
||||
|
||||
def derived(fn):
|
||||
"""A value a check grades. An exception becomes `_Raised`, which equals
|
||||
nothing and is falsy - NOT a default. See test_price.py for why."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _Raised(e)
|
||||
|
||||
|
||||
def near(a, b, tol=1e-9):
|
||||
try:
|
||||
return abs(float(a) - float(b)) <= tol
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
DAY_P = 0.3410 # all-in EUR/kWh, day
|
||||
NIGHT_P = 0.2570 # all-in EUR/kWh, night
|
||||
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
|
||||
|
||||
TWO_PERIOD = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
]}
|
||||
|
||||
FULLY_FIXED = {"periods": [
|
||||
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": FIXED_P},
|
||||
]}
|
||||
|
||||
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
|
||||
# than the mean" and "is the minimum price" are the same set of hours, so a
|
||||
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
|
||||
# Here the middle level sits BELOW the mean without being the minimum (the four
|
||||
# expensive evening hours pull the mean up past it), so those two rules give
|
||||
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
|
||||
# anything with it.
|
||||
THREE_LEVEL = {"periods": [
|
||||
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
|
||||
"eur_per_kwh": 0.4800},
|
||||
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.1900},
|
||||
]}
|
||||
|
||||
|
||||
def opts(profile=None, **over):
|
||||
o = {"price_provider": "off",
|
||||
"static_profile": "" if profile is None else json.dumps(profile)}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("0. the arithmetic this file grades with must not be degenerate")
|
||||
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
|
||||
# any goes red, every shifting assertion below is measuring nothing.
|
||||
check("day and night prices differ, so 'used the wrong window' is visible",
|
||||
DAY_P != NIGHT_P)
|
||||
check("...and neither is zero, so 'dropped the price entirely' is visible",
|
||||
DAY_P != 0 and NIGHT_P != 0)
|
||||
check("...and the fixed price is neither of them, so a fixed-tariff result "
|
||||
"cannot be a day/night result in disguise",
|
||||
FIXED_P not in (DAY_P, NIGHT_P))
|
||||
check("...and the day/night spread is not the fixed price, so 'reported the "
|
||||
"spread where the price was wanted' is visible",
|
||||
not near(DAY_P - NIGHT_P, FIXED_P))
|
||||
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
|
||||
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1. AC1 - the two-period profile puts the right window on the right hour")
|
||||
|
||||
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
|
||||
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
|
||||
isinstance(TWO, StaticProfile))
|
||||
check("...and so does an already-parsed dict (what /data/options.json would "
|
||||
"carry under a nested schema, and what sim/inject.py hands over)",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
check("...and a bare list of periods, without the wrapper",
|
||||
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
|
||||
|
||||
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
|
||||
check("the product is derived from the number of distinct prices, not declared: "
|
||||
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
|
||||
check("...and one level -> FIXED, from the same code, with no flag anywhere",
|
||||
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
|
||||
# ...and it is really the PRICES, not the row count: three rows, one price.
|
||||
_three_rows_one_price = parse_static_profile({"periods": [
|
||||
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
|
||||
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
|
||||
"is on the prices and not on len(periods)",
|
||||
_three_rows_one_price.product is PriceProduct.FIXED
|
||||
and len(_three_rows_one_price.periods) == 3)
|
||||
|
||||
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
|
||||
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
|
||||
THU = datetime(2026, 1, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def at_local(day, hh, mm=0):
|
||||
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
|
||||
|
||||
Built by search rather than by adding an offset, so this helper cannot
|
||||
inherit the same offset bug it is meant to detect.
|
||||
"""
|
||||
for minutes in range(-180, 24 * 60 + 180):
|
||||
u = day + timedelta(minutes=minutes)
|
||||
loc = brussels(u)
|
||||
if loc.hour == hh and loc.minute == mm and loc.date() == \
|
||||
brussels(day + timedelta(hours=12)).date():
|
||||
return u
|
||||
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
|
||||
|
||||
|
||||
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
|
||||
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
|
||||
(12, 0, "day"), (3, 0, "night")]
|
||||
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
|
||||
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
|
||||
f"21:59 day, 22:00 night {_got}",
|
||||
all(name == want for (hh, mm, want), (_, __, name)
|
||||
in zip(_probes, _got)))
|
||||
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
|
||||
"night, which a [22:00,24:00) reading would call uncovered",
|
||||
TWO.at(at_local(THU, 3)).name == "night")
|
||||
|
||||
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
|
||||
# morning - a period applies on the day the CLOCK reads. The weekend row is what
|
||||
# covers Saturday 03:00, and it is a DIFFERENT Period object.
|
||||
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
|
||||
_sat3 = TWO.at(at_local(SAT, 3))
|
||||
_thu3 = TWO.at(at_local(THU, 3))
|
||||
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
|
||||
"window bleeding over - same name and price, different period",
|
||||
_sat3.name == _thu3.name and _sat3 is not _thu3
|
||||
and 5 in _sat3.days and 5 not in _thu3.days)
|
||||
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
|
||||
"runs all day - a weekday-shaped lookup would call it day",
|
||||
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
|
||||
check("...while Thursday 12:00 is day-priced, so the two are really different "
|
||||
"answers and not one answer twice",
|
||||
near(TWO.price_at(at_local(THU, 12)), DAY_P))
|
||||
|
||||
# next_change: the shifting primitive - "how long until the cheap window".
|
||||
_nc = TWO.next_change(at_local(THU, 12))
|
||||
check(f"next_change from Thursday 12:00 is the 22:00 flip "
|
||||
f"{None if _nc is None else brussels(_nc).isoformat()}",
|
||||
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
|
||||
_nc2 = TWO.next_change(at_local(SAT, 12))
|
||||
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
|
||||
"is one window - a scan that stopped at midnight would say 24 h",
|
||||
_nc2 is not None and brussels(_nc2).hour == 7
|
||||
and brussels(_nc2).weekday() == 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
|
||||
|
||||
FIX = parse_static_profile(FULLY_FIXED)
|
||||
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
|
||||
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
|
||||
# local, and `price_at` would correctly raise for it. That raise is the right
|
||||
# behaviour (an hour outside the series is an hour with no price) but it is not
|
||||
# what these sections are grading.
|
||||
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
|
||||
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
|
||||
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
|
||||
|
||||
check("the fixed plan really produced a series (so the refusal below is about "
|
||||
"the tariff, not about a failed read)",
|
||||
p_fixed.has_prices and len(p_fixed.read.points) == 72)
|
||||
_spread_fixed = derived(p_fixed.spread_eur_kwh)
|
||||
_spread_two = derived(p_two.spread_eur_kwh)
|
||||
check(f"...and its spread is exactly zero {_spread_fixed}",
|
||||
near(_spread_fixed, 0.0))
|
||||
check(f"...while the two-period spread is the day/night difference "
|
||||
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
||||
|
||||
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
|
||||
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
|
||||
check("...for the true reason - the series is flat, so there is nothing to buy "
|
||||
"low and sell high, not because a FIXED enum was matched on",
|
||||
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
|
||||
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
|
||||
"refusal is discriminating rather than universal",
|
||||
_v_two.kind == "allowed")
|
||||
|
||||
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
|
||||
# price all day must be refused for the same reason, or the rule is a label
|
||||
# check wearing arithmetic's clothes.
|
||||
_flat_dayahead = TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(start=brussels(THU + timedelta(hours=i)),
|
||||
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
|
||||
None, "provider")
|
||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||
"is arithmetic and not a product label",
|
||||
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
|
||||
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
||||
"dearest hour - the whole input set for a trade",
|
||||
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
|
||||
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
|
||||
and near(_v_two.dearest.eur_per_kwh, DAY_P))
|
||||
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
|
||||
"Absence rather than answering 0.0, which is the number that would make "
|
||||
"every hour look equally good",
|
||||
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
|
||||
and raises(lambda: _v_fixed.cheapest, Absence))
|
||||
check("...and it is not iterable and has no .get(), the other two routes from "
|
||||
"a refusal to an empty, harmless-looking loop",
|
||||
raises(lambda: list(_v_fixed), Absence)
|
||||
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
|
||||
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
|
||||
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
|
||||
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
|
||||
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
|
||||
|
||||
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
|
||||
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||
"the peak guard is holding",
|
||||
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
|
||||
check("...and the reason names the ordering rather than the tariff",
|
||||
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
|
||||
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||
"and keyword-only, so a spread is not obtainable without answering it",
|
||||
raises(lambda: p_two.arbitrage(), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(False), TypeError))
|
||||
check("...nor answered with a truthy 'unknown'",
|
||||
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the cheap-window rule, on a tariff where it is not degenerate")
|
||||
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
|
||||
# minimum" select the SAME hours, so a two-period profile cannot tell
|
||||
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
|
||||
# level sits between the two, and the check below PROVES the two rules disagree
|
||||
# before anything is graded with it.
|
||||
|
||||
THREE = parse_static_profile(THREE_LEVEL)
|
||||
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
|
||||
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
|
||||
_mean3 = p3.mean_eur_kwh()
|
||||
_min3 = min(p.eur_per_kwh for p in p3.read.points)
|
||||
_day3 = 0.2900
|
||||
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
|
||||
f"strictly below the middle level {_day3:.4f}, which is strictly below "
|
||||
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
|
||||
f"DIFFERENT sets of hours and this fixture can tell them apart",
|
||||
_min3 < _day3 < _mean3)
|
||||
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
|
||||
"the mean without being the cheapest price of the day, which a "
|
||||
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
|
||||
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
|
||||
check("...and the middle level really is not the minimum, so that check graded "
|
||||
"the difference rather than restating it",
|
||||
not near(p3.price_at(at_local(THU, 12)), _min3))
|
||||
check("...and calls the 00:00-07:00 minimum cheap",
|
||||
p3.cheap_now(at_local(THU, 3)) is True)
|
||||
check("...and the 17:00-21:00 peak not cheap",
|
||||
p3.cheap_now(at_local(THU, 18)) is False)
|
||||
|
||||
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
|
||||
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
|
||||
# the battery up from the grid at any hour - the opposite of self-consumption
|
||||
# only.
|
||||
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
|
||||
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
|
||||
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
|
||||
f"which is what `<` buys and `<=` would destroy",
|
||||
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
|
||||
check("...and the price really is defined at every one of those hours, so the "
|
||||
"'no cheap window' answer is a priced no and not a missing read",
|
||||
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
|
||||
# ...and on the two-period tariff the cheap hours are exactly the night ones.
|
||||
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
|
||||
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
|
||||
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
|
||||
f"two ways {sorted(_cheap_h)}",
|
||||
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
|
||||
|
||||
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
|
||||
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
|
||||
# cheap or dear depending on when the question was put.
|
||||
FRI = datetime(2026, 1, 16, tzinfo=UTC)
|
||||
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
|
||||
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
|
||||
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
check(f"the 7-day mean is stable across the day it is asked on "
|
||||
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
|
||||
f"property of the tariff and not of the clock",
|
||||
near(_mean_thu, _mean_fri, 5e-4))
|
||||
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
|
||||
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
|
||||
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
|
||||
f"{_mean48_fri:.4f}), so the seven days are buying something real",
|
||||
not near(_mean48_thu, _mean48_fri, 5e-4))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
|
||||
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
|
||||
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
|
||||
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
|
||||
# which is itself worth knowing, because the wrap-around weekday row is not what
|
||||
# is under test here.
|
||||
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
|
||||
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
|
||||
|
||||
|
||||
def local_day_points(profile, day_utc):
|
||||
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
|
||||
target = brussels(day_utc + timedelta(hours=12)).date()
|
||||
pts = profile.series(day_utc - timedelta(hours=6),
|
||||
day_utc + timedelta(hours=30), provisional=False)
|
||||
return [p for p in pts if p.start.date() == target]
|
||||
|
||||
|
||||
_spring = derived(lambda: local_day_points(TWO, SPRING))
|
||||
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
|
||||
check(f"the spring-forward local day emits 23 points, not 24 "
|
||||
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
|
||||
not isinstance(_spring, _Raised) and len(_spring) == 23)
|
||||
check(f"the autumn-back local day emits 25 points, not 24 "
|
||||
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
|
||||
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
|
||||
check("...and the two 02:00 points in October are DIFFERENT instants at "
|
||||
"DIFFERENT offsets, which a naive local-hour key would collide",
|
||||
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
|
||||
check("...and every emitted start is strictly increasing in UTC, which is what "
|
||||
"price.py's boundary check demands of a fetched feed too",
|
||||
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
|
||||
for a, b in zip(_autumn, _autumn[1:])))
|
||||
# A weekday profile across a transition, so the wrap-around path sees one too.
|
||||
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
|
||||
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
|
||||
"and not a systematic miscount", len(_wrapdays) == 24)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. a failed read is not an observation, and a gap is not a price")
|
||||
|
||||
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
|
||||
check("no static_profile produces a NO-DATA read, not an empty series and not "
|
||||
"a zero one", not _none.has_prices)
|
||||
check("...and reading a price off it RAISES rather than answering 0.0 - the "
|
||||
"value that would make every hour look free",
|
||||
raises(lambda: _none.price_at(THU), Absence)
|
||||
and raises(_none.mean_eur_kwh, Absence)
|
||||
and raises(_none.spread_eur_kwh, Absence))
|
||||
check("...and its underlying read has no .points at all, so `min(read.points)` "
|
||||
"cannot run against it",
|
||||
raises(lambda: _none.read.points, Absence))
|
||||
check("...and arbitrage is refused, per §11.4",
|
||||
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
and arbitrage_allowed(_none.read) is False)
|
||||
check("...and static_status() says so in words rather than reporting a price",
|
||||
static_status(opts(None), THU) == (False, "no static profile"))
|
||||
|
||||
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
|
||||
# serve it are to invent one or to fail when the clock reaches it.
|
||||
_gap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
|
||||
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
|
||||
check("...and names the uncovered range so the operator can fix it",
|
||||
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
|
||||
_overlap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("...and an overlap is refused too - two prices for one hour means one "
|
||||
"wins on list order",
|
||||
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
|
||||
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
|
||||
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
|
||||
_noweekend = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a weekday-only profile is refused because Saturday and Sunday have no "
|
||||
"price - the mistake a wrap-around window invites",
|
||||
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
|
||||
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
|
||||
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
|
||||
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
|
||||
"discriminates rather than refusing everything",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
|
||||
_bad = [
|
||||
("not JSON at all", "day 07:00-22:00 0.34"),
|
||||
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh=30.7)]}),
|
||||
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh="cheap")]}),
|
||||
("a missing days selector", {"periods": [
|
||||
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
|
||||
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
days="mon-frd")]}),
|
||||
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
**{"from": "7.5"})]}),
|
||||
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
|
||||
("an empty periods list", {"periods": []}),
|
||||
]
|
||||
for label, value in _bad:
|
||||
payload = value if isinstance(value, str) else json.dumps(value)
|
||||
check(f"refused: {label}",
|
||||
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
|
||||
check("...and every one of those refusals says something an operator can act "
|
||||
"on, rather than just failing",
|
||||
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
|
||||
parse_static_profile(p))) > 40 for _, v in _bad))
|
||||
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
|
||||
("whitespace", " ")):
|
||||
check(f"...while {label!r} means 'no profile' rather than an error",
|
||||
parse_static_profile(value) is None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
|
||||
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
|
||||
# six scenarios; none of them is a static-profile case, which is why AC3 asks
|
||||
# for a seventh. What exists as DATA is two days of this house's real P1 net
|
||||
# power, captured from the live meter and committed as
|
||||
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
|
||||
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
|
||||
#
|
||||
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
|
||||
# happen to be one weekday and one weekend day, which is exactly the pair a
|
||||
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
|
||||
# section does not claim to be one; the numbers it prints are the numbers those
|
||||
# two days actually produce.
|
||||
|
||||
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
||||
print(f" replay fixtures found: {len(REPLAY)} "
|
||||
f"({', '.join(p.name for p in REPLAY) or 'none'})")
|
||||
check("the committed replay fixtures are present - a regression fixture CI "
|
||||
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
||||
|
||||
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
|
||||
|
||||
|
||||
def replay_import(path):
|
||||
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
|
||||
|
||||
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
|
||||
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
|
||||
- rather than by asking the module under test. The two must agree; that
|
||||
agreement is the check.
|
||||
"""
|
||||
per_window = {}
|
||||
total = 0.0
|
||||
first = last = None
|
||||
prev = None
|
||||
days = set()
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if first is None:
|
||||
first = t
|
||||
last = t
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
kwh = prev[1] * dt / 3_600_000.0
|
||||
loc = brussels(prev[0])
|
||||
days.add(loc.weekday())
|
||||
window = ("day" if loc.weekday() < 5
|
||||
and 7 <= loc.hour < 22 else "night")
|
||||
per_window[window] = per_window.get(window, 0.0) + kwh
|
||||
total += kwh
|
||||
prev = (t, w)
|
||||
span = (last - first).total_seconds() / 3600.0 if first else 0.0
|
||||
return per_window, total, span, days
|
||||
|
||||
|
||||
def cost_under(path, profile):
|
||||
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
|
||||
total = 0.0
|
||||
prev = None
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
|
||||
prev = (t, w)
|
||||
return total
|
||||
|
||||
|
||||
replay_days = 0.0
|
||||
graded = 0
|
||||
for path in REPLAY:
|
||||
windows, total_kwh, span_h, weekdays = replay_import(path)
|
||||
replay_days += span_h / 24.0
|
||||
day_kwh = windows.get("day", 0.0)
|
||||
night_kwh = windows.get("night", 0.0)
|
||||
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
|
||||
print(f" {path.name}: {span_h:.1f} h, {kind} "
|
||||
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
|
||||
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
|
||||
|
||||
check(f"{path.name}: the two routes to the total agree",
|
||||
near(day_kwh + night_kwh, total_kwh, 1e-9))
|
||||
|
||||
# The module's own pricing must reproduce the independently-split volumes.
|
||||
c_two = cost_under(path, TWO)
|
||||
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
|
||||
check(f"{path.name}: the module prices the real day at "
|
||||
f"EUR {c_two:.4f}, which equals the independently-split "
|
||||
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
|
||||
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
|
||||
|
||||
c_fix = cost_under(path, FIX)
|
||||
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
|
||||
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
|
||||
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
|
||||
|
||||
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
|
||||
# whose entire import landed in one window grades nothing: the saving would
|
||||
# be 0 under a correct implementation AND under one that ignored the clock.
|
||||
if day_kwh > 0 and night_kwh > 0:
|
||||
graded += 1
|
||||
saving = c_two - total_kwh * NIGHT_P
|
||||
check(f"{path.name}: this day DOES import in both windows "
|
||||
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
|
||||
f"something", True)
|
||||
check(f"{path.name}: shifting every day-window kWh into the night "
|
||||
f"window saves EUR {saving:.4f}, which is exactly "
|
||||
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
|
||||
f"routes to one number",
|
||||
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
|
||||
# AC2 on the same real data: under a fixed tariff there is nothing to
|
||||
# shift, and that is arithmetic rather than a special case.
|
||||
check(f"{path.name}: under the FIXED tariff the identical shift saves "
|
||||
f"exactly nothing, so self-consumption-only is the right "
|
||||
f"behaviour for that product",
|
||||
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
|
||||
else:
|
||||
print(f" {path.name}: import fell in ONE window only "
|
||||
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
|
||||
f"day-typing witness, not a shifting one, and no saving is "
|
||||
f"claimed from it")
|
||||
|
||||
print()
|
||||
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
|
||||
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
|
||||
f"windows and therefore grading the split.")
|
||||
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
|
||||
f"ONE - but the suite holds two captured days, not a season, so nothing "
|
||||
f"here is a claim about annual savings. §17's six named scenarios do not "
|
||||
f"include a static-profile case; this is the seventh, and TEST-01 should "
|
||||
f"say so.")
|
||||
check("the replay produced a measurable split over a stated sample size, "
|
||||
"rather than a criterion quietly redefined to fit the data",
|
||||
graded >= 1 and replay_days < 30)
|
||||
check("...and BOTH day types are present in the fixtures, which is what makes "
|
||||
"the weekend row testable on real data at all",
|
||||
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
|
||||
for p in REPLAY}) == 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
|
||||
|
||||
|
||||
async def _dead(url, timeout_s):
|
||||
raise OSError("the supplier's feed is down")
|
||||
|
||||
|
||||
def run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed falls back to the static profile rather than to nothing",
|
||||
_fb.origin == "fallback" and _fb.has_prices)
|
||||
check("...with the SAME prices the configured-product path produces, because "
|
||||
"it is one generator and not a second implementation",
|
||||
[p.eur_per_kwh for p in _fb.read.points]
|
||||
== [p.eur_per_kwh for p in static_plan(
|
||||
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
|
||||
check("...marked PROVISIONAL, which is the one flag that differs",
|
||||
_fb.provisional and plan_is_provisional(_fb.read))
|
||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||
"exists and has a real spread",
|
||||
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||
check("...while the SAME profile as the configured product allows it - so the "
|
||||
"refusal is about provisionality and not about the profile",
|
||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
||||
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
|
||||
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
|
||||
"irreversible action on a provisional price, and a grid top-up is one",
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||
price_timeout_s=1),
|
||||
THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
"says that rather than inventing a tariff",
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
return _body
|
||||
|
||||
|
||||
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
||||
transport=_live))
|
||||
check("a live feed is used in preference to the static profile",
|
||||
_live_plan.origin == "provider"
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||
"feed rather than from the profile",
|
||||
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
|
||||
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
|
||||
0.023, 1e-9))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8. the startup gate, and CFG-03")
|
||||
|
||||
|
||||
def vbase(**over):
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
def why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
check("a good static_profile starts",
|
||||
validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
|
||||
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000),
|
||||
"static_profile cannot be used"))
|
||||
check("...and the refusal names the uncovered range",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000), "22:00-23:00"))
|
||||
check("static_profile alongside the Home Assistant price entities is refused - "
|
||||
"main.py reads one of them and the loser is silent",
|
||||
why(validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD),
|
||||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||||
-5000, 5000), "HOME ASSISTANT"))
|
||||
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
|
||||
"away from a live install",
|
||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
-5000, 5000) == [])
|
||||
check("...and an empty static_profile is never refused: §9.2 puts a large share "
|
||||
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
|
||||
"supported state",
|
||||
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
|
||||
and validate_startup_config(vbase(), -5000, 5000) == [])
|
||||
check("...and a fully-fixed profile starts, since a one-price site is a "
|
||||
"supported product and not a misconfiguration",
|
||||
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
|
||||
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
|
||||
# created and pinned to config.yaml - rather than to a second defaults dict in
|
||||
# the new module. That mirror is enforced by test_price.py section 1, which
|
||||
# reads config.yaml; this check is the other half of the claim: the new module
|
||||
# must carry NO defaults dict of its own.
|
||||
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
|
||||
check("the new module holds no option-defaults dict of its own, so this ticket "
|
||||
"added ZERO new places an option default lives (CFG-03)",
|
||||
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
|
||||
check("...and it reads the option through price.price_option(), the framework's "
|
||||
"single read path, rather than through opts.get(key, <a number>)",
|
||||
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
|
||||
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
|
||||
"either - it calls static_status(o, now), and the option is read once, "
|
||||
"inside the framework",
|
||||
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,935 @@
|
||||
"""Runnable check for TARIFF-01, the capacity-tariff measurement.
|
||||
|
||||
python3 test_tariff.py
|
||||
|
||||
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||
NOT allowed to be tested at:
|
||||
|
||||
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
|
||||
segment. The fixture is chosen to be non-degenerate on three axes at once:
|
||||
irregular sample spacing, a sample that STRADDLES the :15 boundary, and a
|
||||
gap longer than `max_hold_s` so the block's coverage is short of 900 s.
|
||||
2. THE 900 s DEGENERACY, named and then avoided. `acc/elapsed`, `acc/900` and
|
||||
the end-of-quarter projection are THREE DIFFERENT FORMULAS that all agree
|
||||
at exactly full coverage of a complete block. A check run only there proves
|
||||
nothing about any of them, so part 2 first shows them agreeing at that
|
||||
point (so the trap is documented rather than asserted around), then shows
|
||||
part 1's fixture separating all three - and every later part uses fixtures
|
||||
that are away from it.
|
||||
3. RESTART MID-BLOCK, through the real `QuarterAverager.snapshot()`/`restore()`
|
||||
and a real SQLite file: a restarted run must land on the same closed block
|
||||
as an uninterrupted one, and a run that DOES NOT restore must land lower.
|
||||
Without that second half, "restore works" is satisfied by a fixture where
|
||||
restoring changes nothing.
|
||||
4. MONTH ROLLOVER, in LOCAL time, including a restart that straddles it.
|
||||
5. AN UNREADABLE STORE IS NOT A ZERO PEAK - the acceptance argument. Three
|
||||
states, three different answers, and no accessor that turns "the database
|
||||
did not answer" into a number.
|
||||
6. THE TIMEZONE FALLBACK, graded against `zoneinfo` day by day.
|
||||
7. THE PER-PHASE REDUCTION on an UNBALANCED load. §17's three-phase case. On a
|
||||
balanced load net/sum/max all coincide, which is why a balanced fixture is
|
||||
worth nothing here.
|
||||
8. `monthly_peak` vs a recompute from `quarter_blocks` - the derived record
|
||||
proven equal to the tier it derives from, and proven never to be lowered.
|
||||
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
|
||||
10. THE WIRING, through the real `Controller` and its real status page.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ PART 9 IS A TWO-DAY MEASUREMENT AND SAYS SO. THE 30-DAY ANSWER IS ELSEWHERE
|
||||
(DATA-01), AND IT DOES NOT LAND WHERE ANYONE EXPECTED.
|
||||
|
||||
AC 3 asks for "the monthly peak held under the soft limit in >= 95 % of
|
||||
intervals over a 30-day simulation replay". When this file was written there
|
||||
were two days of this house's real meter - 2026-08-20 (the Illusmart failure
|
||||
day) and 2026-08-23 (the healthy reference) - exported on 2026-08-25 because
|
||||
they were about to age out of HA's recorder.
|
||||
|
||||
⚠️ AND THE SENTENCE THAT USED TO STAND HERE, "nothing longer was ever
|
||||
captured", WAS TRUE ABOUT THE PAST AND WAS READ AS A CLAIM ABOUT THE FUTURE.
|
||||
It was not one. HA's recorder still held the whole month; DATA-01 probed it on
|
||||
2026-08-31 (230 samples in a 20-minute window at 30 days back, 0 at 45) and
|
||||
exported thirty days the same day, into `scenarios/p1-net-30d-2026-08/` with
|
||||
`tools/export_p1_history.py` - which is committed now precisely because the
|
||||
2026-08-25 one-off was not, and its absence is what let the belief survive.
|
||||
|
||||
Over those thirty days the criterion measures 98.89 % UNCONTROLLED and 98.99 %
|
||||
guarded, so it is met - and that is a finding about the CRITERION as much as
|
||||
about the guard, because a month with no controller at all passes it. Read the
|
||||
other way - the monthly PEAK itself under the soft limit - it is not met: the
|
||||
guard takes August's peak from 5029.8 W to 4107.3 W, still over 3500 W. Both
|
||||
numbers, and why they part company, are in
|
||||
`scenarios/p1-net-30d-2026-08/README.md`.
|
||||
|
||||
PART 9 IS DELIBERATELY LEFT GRADING THE TWO COMMITTED DAYS. It is a per-PR
|
||||
regression check and the thirty days cost about fifty seconds of replay; they
|
||||
also live one directory down precisely so this file's glob does not pick them
|
||||
up. `tools/replay_ac3.py` is the thing that runs the month, and it is checked
|
||||
against part 6 of test_peakguard.py rather than trusted:
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
So part 9 replays the two days that exist, reports the percentage over them, and
|
||||
states the sample size in days next to it. It is a MEASUREMENT, not a verdict:
|
||||
nothing in it exits non-zero on the 95 % figure, because a 2-day run cannot pass
|
||||
or fail a 30-day criterion and a check that claimed it could would be the more
|
||||
expensive of the two mistakes. Both days are also *uncontrolled* - the add-on
|
||||
was not shaving peaks on either - so the figure they give is the BASELINE the
|
||||
guard has to improve on, which is the useful thing a 2-day replay can say.
|
||||
|
||||
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
|
||||
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
|
||||
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
|
||||
infra repo is private, CI's token is minted for this repository only, and a
|
||||
regression fixture that CI cannot see is not a regression fixture.
|
||||
|
||||
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
|
||||
why the committed copy is the artefact and not the API call.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.main import Controller
|
||||
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||
from app.tariff import (
|
||||
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
|
||||
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
|
||||
month_key,
|
||||
)
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
|
||||
|
||||
# The live install's numbers: NOTES.md and freeze.py both record max_w 5000
|
||||
# there. `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3
|
||||
# grades against - read out of the shipped file in part 10 rather than typed.
|
||||
LIVE_MAX_W = 5000.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def near(a, b, tol=1e-6):
|
||||
return a is not None and b is not None and abs(a - b) <= tol
|
||||
|
||||
|
||||
def sample(t: datetime, net_w: float, phases=1, phase_net=None):
|
||||
"""One P1 telegram, through the SHIPPED derivation.
|
||||
|
||||
`split_signed` and `make_sample` are what the real transports call, so the
|
||||
sign convention under test is the one that ships rather than a local
|
||||
re-derivation of it.
|
||||
"""
|
||||
imp, exp = split_signed(net_w)
|
||||
pi = pe = None
|
||||
if phase_net is not None:
|
||||
pairs = [split_signed(v) for v in phase_net]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
return make_sample("ha_signed", imp, exp, phases=phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
ingest_ts=t, ingest_mono=t.timestamp())
|
||||
|
||||
|
||||
def fresh_store(name: str) -> PeakStore:
|
||||
return PeakStore(str(TMP / f"{name}.db"))
|
||||
|
||||
|
||||
# B is a clock-aligned quarter boundary: proved, not assumed, because every
|
||||
# other number in part 1 rests on it.
|
||||
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the block arithmetic, on a worked example")
|
||||
|
||||
check(f"the fixture's start {B.isoformat()} really is 900 s aligned",
|
||||
int(B.timestamp()) % QUARTER_S == 0)
|
||||
|
||||
# (t seconds after B, net W). Deliberately irregular, with an export, a gap
|
||||
# longer than max_hold_s, and a last sample that straddles the boundary.
|
||||
MAX_HOLD_S = 30.0
|
||||
FIXTURE = [(0, 1000.0), (20, 2000.0), (30, -500.0), (50, 3000.0),
|
||||
(250, 4000.0), (880, 1200.0), (1000, 900.0)]
|
||||
|
||||
# The worked example, written as the segments a person would compute by hand:
|
||||
# each HELD value and how many seconds of the FIRST block it is evidence for.
|
||||
# Two rules do all the work: a sample is held forward until the next one, capped
|
||||
# at MAX_HOLD_S; and export is 0 W of offtake, never a negative contribution.
|
||||
SEGMENTS = [
|
||||
(1000.0, 20), # 0 -> 20, a normal telegram interval, held in full
|
||||
(2000.0, 10), # 20 -> 30, likewise
|
||||
(0.0, 20), # 30 -> 50: -500 W is EXPORT, so 0 W of offtake for 20 s
|
||||
(3000.0, 30), # 50 -> 250 is a 200 s GAP; only the first 30 s is evidence
|
||||
(4000.0, 30), # 250 -> 880 is a 630 s gap; again only 30 s is evidence
|
||||
(1200.0, 20), # 880 -> 900: the STRADDLE's first half, up to the boundary
|
||||
]
|
||||
# The straddle's second half: the same held 1200 W, from the boundary until its
|
||||
# hold expires at 880 + 30. It must be credited to the NEXT block, not to this
|
||||
# one and not to neither.
|
||||
SPLIT_TAIL_W, SPLIT_TAIL_S = 1200.0, 10
|
||||
EXP_WS = sum(w * s for w, s in SEGMENTS)
|
||||
EXP_COVERED_S = sum(s for _, s in SEGMENTS)
|
||||
EXP_BLOCK_AVG = EXP_WS / QUARTER_S # what the utility bills
|
||||
EXP_ELAPSED_AVG = EXP_WS / EXP_COVERED_S # what a /elapsed divisor gives
|
||||
NAIVE_MEAN = sum(max(w, 0.0) for _, w in FIXTURE[:-1]) / (len(FIXTURE) - 1)
|
||||
|
||||
avg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
closed = []
|
||||
for dt_s, w in FIXTURE:
|
||||
closed += avg.add(sample(B + timedelta(seconds=dt_s), w))
|
||||
|
||||
print(f" {len(SEGMENTS)} held segments -> {EXP_WS:,.0f} Ws over "
|
||||
f"{EXP_COVERED_S:.0f} s of the {QUARTER_S} s block")
|
||||
check(f"exactly one block closed", len(closed) == 1)
|
||||
check(f"...starting at the fixture's own boundary {B.isoformat()}",
|
||||
bool(closed) and closed[0].start == B)
|
||||
check(f"...averaging {EXP_BLOCK_AVG:.2f} W - the worked example's "
|
||||
f"{EXP_WS:,.0f} Ws over the FULL {QUARTER_S} s",
|
||||
bool(closed) and near(closed[0].offtake_avg_w, EXP_BLOCK_AVG, 1e-9))
|
||||
|
||||
# The three ways of being wrong, each of which produces a plausible number.
|
||||
print(f" the same data read three other ways: /elapsed = {EXP_ELAPSED_AVG:.2f} W, "
|
||||
f"naive mean of samples = {NAIVE_MEAN:.2f} W, "
|
||||
f"billed = {EXP_BLOCK_AVG:.2f} W")
|
||||
check("...and /elapsed is NOT the billed figure - a gap must drag the average "
|
||||
"down, not be averaged away",
|
||||
abs(EXP_ELAPSED_AVG - EXP_BLOCK_AVG) > 1.0)
|
||||
check("...and a naive mean over samples is NOT the billed figure either - it "
|
||||
"weights a burst of fast telegrams like a slow one",
|
||||
abs(NAIVE_MEAN - EXP_BLOCK_AVG) > 1.0)
|
||||
|
||||
# The straddle: the 4000 W sample at t=400 is held ACROSS 900. Its evidence
|
||||
# expires at 430, so the second block gets none of it - and the second block
|
||||
# must still have been OPENED at 900, not at 1000.
|
||||
check(f"the block after the straddle opens at the boundary, not at the sample",
|
||||
avg.block_start == B + timedelta(seconds=QUARTER_S))
|
||||
print(f" the straddling sample is SPLIT at the boundary: "
|
||||
f"{SEGMENTS[-1][0]:.0f} W x {SEGMENTS[-1][1]} s into the first block, "
|
||||
f"x {SPLIT_TAIL_S} s into the second")
|
||||
check(f"...and the second block is credited the tail "
|
||||
f"({SPLIT_TAIL_W * SPLIT_TAIL_S:,.0f} Ws), so a sample spanning :15 is "
|
||||
f"attributed to BOTH blocks in proportion and wholly to neither",
|
||||
near(avg.partial_ws, SPLIT_TAIL_W * SPLIT_TAIL_S, 1e-9))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. the 900 s degeneracy - where three different formulas agree")
|
||||
|
||||
# A complete block at a constant power, fully covered. Here acc/elapsed,
|
||||
# acc/900 and the projection are ALL the same number. Anything checked only
|
||||
# here is checked against nothing.
|
||||
HELD_W = 2400.0
|
||||
deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
deg_closed = []
|
||||
for i in range(0, QUARTER_S + 1, 5):
|
||||
deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W))
|
||||
d_block = deg_closed[0].offtake_avg_w
|
||||
deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
LAST_S = QUARTER_S - 5
|
||||
for i in range(0, LAST_S + 1, 5):
|
||||
deg2.add(sample(B + timedelta(seconds=i), HELD_W))
|
||||
d_partial = deg2.offtake_avg_w
|
||||
# Graded AT the last sample: full coverage behind it, a held value in front.
|
||||
d_proj = PeakTracker.project(deg2, B + timedelta(seconds=LAST_S)).projected_w
|
||||
print(f" at full coverage of a whole block: acc/900 = {d_block:.2f} W, "
|
||||
f"acc/elapsed = {d_partial:.2f} W, projection = {d_proj:.2f} W")
|
||||
check("all three formulas coincide at exactly 900 s of full coverage - so a "
|
||||
"check placed only there distinguishes none of them",
|
||||
near(d_block, HELD_W, 1e-6) and near(d_partial, HELD_W, 1e-6)
|
||||
and near(d_proj, HELD_W, 1e-6))
|
||||
|
||||
# Away from it, on part 1's fixture at a point mid-block, they must separate.
|
||||
mid = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
for dt_s, w in FIXTURE[:-1]:
|
||||
mid.add(sample(B + timedelta(seconds=dt_s), w))
|
||||
NOW = B + timedelta(seconds=500)
|
||||
p = PeakTracker.project(mid, NOW)
|
||||
print(f" at t=500 s on part 1's fixture: acc/elapsed = {p.elapsed_w:.2f} W, "
|
||||
f"projection = {p.projected_w:.2f} W, coverage = {p.covered_frac * 100:.1f} %")
|
||||
check("mid-block, with a gap in it, the elapsed average and the projection are "
|
||||
"DIFFERENT numbers", abs(p.elapsed_w - p.projected_w) > 1.0)
|
||||
check("...and the projection carries elapsed_s, so a consumer can see it is an "
|
||||
f"extrapolation over {QUARTER_S - p.elapsed_s:.0f} s it has not seen",
|
||||
near(p.elapsed_s, 500.0, 1e-6))
|
||||
check("...and a gap shows up as coverage below 1.0 rather than as a confident "
|
||||
"low number", p.covered_frac < 1.0 and not p.complete)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. restart mid-block: the same block, or a lower one, never a lost one")
|
||||
|
||||
RUN_W = 2000.0
|
||||
STEP_S = 1
|
||||
RESTART_AT_S = 600 # minute 10 of the quarter - the functional note's case
|
||||
|
||||
|
||||
def feed(avg, t0_s, t1_s, w=RUN_W):
|
||||
out = []
|
||||
for i in range(t0_s, t1_s + 1, STEP_S):
|
||||
out += avg.add(sample(B + timedelta(seconds=i), w))
|
||||
return out
|
||||
|
||||
|
||||
# (a) the control: no restart at all.
|
||||
ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
ctrl_closed = feed(ctrl, 0, QUARTER_S)
|
||||
CTRL_W = ctrl_closed[0].offtake_avg_w
|
||||
|
||||
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
|
||||
st = fresh_store("restart")
|
||||
tr = PeakTracker(st, cap_w=LIVE_MAX_W)
|
||||
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
feed(a1, 0, RESTART_AT_S)
|
||||
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
|
||||
a2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) # a brand-new process
|
||||
restored = tr.restore(a2, B + timedelta(seconds=RESTART_AT_S + 1))
|
||||
print(f" restore() said: {restored}")
|
||||
check("...and restore() reports RESUMING rather than starting fresh",
|
||||
restored.startswith("resumed"))
|
||||
r_closed = feed(a2, RESTART_AT_S + STEP_S, QUARTER_S)
|
||||
RESTART_W = r_closed[0].offtake_avg_w if r_closed else None
|
||||
|
||||
# (c) the mutation guard: the SAME restart with no restore.
|
||||
a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S)
|
||||
NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None
|
||||
|
||||
print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | "
|
||||
f"restart with no restore {NORESTORE_W:.2f} W")
|
||||
check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an "
|
||||
f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6))
|
||||
check("...and without the restore it lands LOWER - so 'the restore works' is "
|
||||
"not satisfied by a fixture where restoring changes nothing",
|
||||
NORESTORE_W is not None and NORESTORE_W < CTRL_W - 1.0)
|
||||
# Derived, not asserted against a literal: the lost part is exactly the seconds
|
||||
# before the restart, at the power that was flowing.
|
||||
DROPPED_S = RESTART_AT_S + STEP_S # the resumed feed starts one step later
|
||||
check(f"...and it is lower by exactly the {DROPPED_S} s it dropped "
|
||||
f"({RUN_W * DROPPED_S / QUARTER_S:.2f} W)",
|
||||
near(CTRL_W - NORESTORE_W, RUN_W * DROPPED_S / QUARTER_S, 1e-6))
|
||||
|
||||
# (d) a LONG outage: restored, but the meter was away past max_hold_s. The
|
||||
# uncovered seconds must drag the block DOWN, not be held forward.
|
||||
OUTAGE_S = 200
|
||||
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
st4 = fresh_store("outage")
|
||||
st4.save_partial(a1.snapshot())
|
||||
PeakTracker(st4, cap_w=LIVE_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
|
||||
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
|
||||
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
|
||||
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
|
||||
f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: "
|
||||
f"{o_closed[0].offtake_avg_w:.2f} W")
|
||||
check(f"...reads lower than the uninterrupted block by exactly the uncovered "
|
||||
f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is "
|
||||
f"never held forward across the whole outage",
|
||||
near(CTRL_W - o_closed[0].offtake_avg_w,
|
||||
RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6))
|
||||
|
||||
# (e) double counting is answered by the primary key, not by care.
|
||||
st5 = fresh_store("dedupe")
|
||||
st5.record_block(ctrl_closed[0])
|
||||
st5.record_block(ctrl_closed[0])
|
||||
st5.record_block(ctrl_closed[0])
|
||||
rows = st5.blocks_in(month_key(B))
|
||||
check("banking the same quarter three times leaves ONE row - quarter_start is "
|
||||
"the primary key, so a replay or a restart cannot double-count",
|
||||
len(rows) == 1 and near(rows[0][1], CTRL_W, 1e-9))
|
||||
|
||||
# ...and a later pass with LESS coverage may not lower a banked figure.
|
||||
from app.tariff import _ClosedBlock as _CB
|
||||
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None))
|
||||
after = st5.blocks_in(month_key(B))
|
||||
check(f"...and re-banking the same quarter at half the figure leaves it at "
|
||||
f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a "
|
||||
f"quarter that was already measured in full",
|
||||
len(after) == 1 and near(after[0][1], CTRL_W, 1e-9))
|
||||
|
||||
# (f) the checkpoint's own staleness bound, derived from the constant.
|
||||
print(f" CHECKPOINT_S = {CHECKPOINT_S:.0f} s, so a crash can lose at most "
|
||||
f"{CHECKPOINT_S / QUARTER_S * 100:.1f} % of one quarter's average")
|
||||
check("the checkpoint bound is small enough that its worst case is under 2 % "
|
||||
"of a quarter", CHECKPOINT_S / QUARTER_S < 0.02)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. month rollover, in LOCAL time, and across a restart that straddles it")
|
||||
|
||||
# CEST is UTC+2, so the last two hours of 31 August UTC are already September.
|
||||
AUG_LATE = datetime(2026, 8, 31, 21, 45, tzinfo=timezone.utc)
|
||||
SEP_EARLY = datetime(2026, 8, 31, 22, 15, tzinfo=timezone.utc)
|
||||
print(f" {AUG_LATE.isoformat()} is local "
|
||||
f"{AUG_LATE.astimezone(BILLING_TZ).isoformat()} -> {month_key(AUG_LATE)}")
|
||||
print(f" {SEP_EARLY.isoformat()} is local "
|
||||
f"{SEP_EARLY.astimezone(BILLING_TZ).isoformat()} -> {month_key(SEP_EARLY)}")
|
||||
check("a quarter starting 21:45 UTC on 31 Aug bills in AUGUST",
|
||||
month_key(AUG_LATE) == "2026-08")
|
||||
check("a quarter starting 22:15 UTC on 31 Aug bills in SEPTEMBER - filing it "
|
||||
"under August would move a whole month's bill",
|
||||
month_key(SEP_EARLY) == "2026-09")
|
||||
check("...and the two are genuinely on opposite sides of the boundary, not "
|
||||
"the same answer twice", month_key(AUG_LATE) != month_key(SEP_EARLY))
|
||||
# Winter, where the offset is +1 and the boundary sits an hour later.
|
||||
JAN_LATE = datetime(2026, 1, 31, 22, 45, tzinfo=timezone.utc)
|
||||
check("in WINTER the same trap is an hour later: 22:45 UTC on 31 Jan is still "
|
||||
"January, and 23:15 UTC is February",
|
||||
month_key(JAN_LATE) == "2026-01"
|
||||
and month_key(JAN_LATE + timedelta(minutes=30)) == "2026-02")
|
||||
|
||||
st6 = fresh_store("months")
|
||||
AUG_PEAK_W, SEP_PEAK_W = 4998.0, 900.0
|
||||
st6.record_block(_CB(AUG_LATE, AUG_PEAK_W, None))
|
||||
st6.record_block(_CB(SEP_EARLY, SEP_PEAK_W, None))
|
||||
r_aug, r_sep = st6.peak(month="2026-08"), st6.peak(month="2026-09")
|
||||
print(f" {r_aug.explain()}")
|
||||
print(f" {r_sep.explain()}")
|
||||
check(f"August's peak is the {AUG_PEAK_W:.0f} W quarter (specs.md:583's own "
|
||||
f"measured case) and September's is not",
|
||||
r_aug.is_observed and near(r_aug.peak_w, AUG_PEAK_W)
|
||||
and r_sep.is_observed and near(r_sep.peak_w, SEP_PEAK_W))
|
||||
check("...so a new month starts from its OWN quarters, not from the previous "
|
||||
"month's peak", r_sep.peak_w < r_aug.peak_w)
|
||||
|
||||
# The restart that straddles the boundary: a checkpoint whose open block is in
|
||||
# August, restored when `now` is already September.
|
||||
st7 = fresh_store("straddle")
|
||||
tr7 = PeakTracker(st7, cap_w=LIVE_MAX_W)
|
||||
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
for i in range(0, 400, STEP_S):
|
||||
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
|
||||
st7.save_partial(straddler.snapshot())
|
||||
after_boot = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
said = tr7.restore(after_boot, SEP_EARLY + timedelta(minutes=3))
|
||||
print(f" restore() across the boundary said: {said}")
|
||||
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
|
||||
check("a restart that straddles the month boundary BANKS the dead August "
|
||||
"quarter rather than dropping measured energy",
|
||||
said.startswith("closed a stale block") and aug7.is_observed)
|
||||
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
|
||||
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
|
||||
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
|
||||
check("...and September reads NO_RECORD, which is a different answer from "
|
||||
"August's peak AND from a zero",
|
||||
sep7.state == NO_RECORD and sep7.peak_w is None)
|
||||
check("...and the checkpoint is cleared, so a second boot cannot re-bank it",
|
||||
st7.load_partial() is None)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
|
||||
|
||||
# Three stores, three states, and the three must not answer alike.
|
||||
good = fresh_store("states")
|
||||
good_tr = PeakTracker(good, cap_w=LIVE_MAX_W)
|
||||
empty_r = good_tr.peak(B)
|
||||
|
||||
gone = fresh_store("gone")
|
||||
gone_tr = PeakTracker(gone, cap_w=LIVE_MAX_W)
|
||||
gone.record_block(_CB(B, 3000.0, None))
|
||||
gone.close() # the database goes away under a live reader
|
||||
gone_r = gone_tr.peak(B)
|
||||
|
||||
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
|
||||
never_tr = PeakTracker(never, cap_w=LIVE_MAX_W)
|
||||
never_r = never_tr.peak(B)
|
||||
|
||||
# A store that is OPEN and whose query FAILS is a third code path - a closed
|
||||
# handle short-circuits before the SQL ever runs, so without this the
|
||||
# `except sqlite3.Error` arm of peak() is never executed by anything.
|
||||
broken = fresh_store("broken-table")
|
||||
broken.record_block(_CB(B, 3000.0, None))
|
||||
broken._db.execute("DROP TABLE monthly_peak")
|
||||
broken._db.commit()
|
||||
broken_r = PeakTracker(broken, cap_w=LIVE_MAX_W).peak(B)
|
||||
|
||||
for label, r in (("fresh store, no quarter yet", empty_r),
|
||||
("store that broke under a live reader", gone_r),
|
||||
("store that never opened", never_r),
|
||||
("store open, query failed", broken_r)):
|
||||
print(f" {label:38s} -> {r.state:13s} {r.explain()}")
|
||||
|
||||
check("a fresh store with no quarter yet says NO_RECORD - a normal state on "
|
||||
"the 1st of the month", empty_r.state == NO_RECORD)
|
||||
check("a store that broke says NOT_OBSERVED", gone_r.state == NOT_OBSERVED)
|
||||
check("a store that never opened says NOT_OBSERVED", never_r.state == NOT_OBSERVED)
|
||||
check("a store whose QUERY fails says NOT_OBSERVED too - a different code path "
|
||||
"from a closed handle, and the one a half-corrupt database takes",
|
||||
broken_r.state == NOT_OBSERVED and broken_r.peak_w is None)
|
||||
# ...and losing the DERIVED record does not lose the peak, which is the whole
|
||||
# argument for keeping monthly_peak recomputable from the 15-min tier.
|
||||
_rec = broken.recompute_month(month_key(B))
|
||||
print(f" ...but with the 15-min tier intact, recompute_month() still "
|
||||
f"answers: {_rec.explain()}")
|
||||
check("...while recompute_month() STILL answers from the surviving 15-min "
|
||||
"tier - the permanent record is derived, so losing it is recoverable",
|
||||
_rec.is_observed and near(_rec.peak_w, 3000.0))
|
||||
broken._db.execute("DROP TABLE quarter_blocks")
|
||||
broken._db.commit()
|
||||
check("...and once BOTH tiers are gone it says NOT_OBSERVED rather than "
|
||||
"raising into the control loop",
|
||||
broken.recompute_month(month_key(B)).state == NOT_OBSERVED)
|
||||
check("...and NO_RECORD and NOT_OBSERVED are DIFFERENT states, not two "
|
||||
"spellings of the same one", empty_r.state != gone_r.state)
|
||||
check("...and they read differently to a human, too",
|
||||
empty_r.explain() != gone_r.explain()
|
||||
and "NOT" in gone_r.explain() and "zero peak" in gone_r.explain())
|
||||
|
||||
check("an unreadable peak is None, not 0.0 - there is no number to mistake",
|
||||
gone_r.peak_w is None and never_r.peak_w is None)
|
||||
check("...and NO_RECORD carries no number either", empty_r.peak_w is None)
|
||||
_typed = False
|
||||
try:
|
||||
float(gone_r.peak_w)
|
||||
except TypeError:
|
||||
_typed = True
|
||||
check("...and a caller who forgets to ask gets a TypeError rather than a "
|
||||
"silently disarming zero", _typed)
|
||||
check("PeakReading exposes no accessor that defaults a missing peak to a "
|
||||
"number - no .get(), no _or() ",
|
||||
not [a for a in dir(gone_r)
|
||||
if a.startswith(("get", "value")) or a.endswith("_or")])
|
||||
|
||||
# The consequence, which is the reason any of this matters.
|
||||
print(f" headroom under a {LIVE_MAX_W:.0f} W limit: "
|
||||
f"no quarter yet -> {good_tr.headroom_w(B)}, "
|
||||
f"unreadable -> {gone_tr.headroom_w(B)}")
|
||||
check("an unreadable store yields NO headroom figure - the disarming default "
|
||||
"(full headroom, guard silently off) is unreachable",
|
||||
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
|
||||
check(f"...where a genuinely empty month yields the full {LIVE_MAX_W:.0f} W, "
|
||||
f"which is correct and is a different answer",
|
||||
near(good_tr.headroom_w(B), LIVE_MAX_W))
|
||||
check("the 80 % warning fraction is likewise None when nothing was observed",
|
||||
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
|
||||
|
||||
# And a real peak reduces headroom, so "always None" does not pass this part.
|
||||
good.record_block(_CB(B, LIVE_MAX_W * 0.9, None))
|
||||
print(f" after one {LIVE_MAX_W * 0.9:.0f} W quarter: "
|
||||
f"headroom {good_tr.headroom_w(B):.0f} W, "
|
||||
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
|
||||
check("a real observed peak gives a real headroom, so this part is not passed "
|
||||
"by a store that answers None to everything",
|
||||
near(good_tr.headroom_w(B), LIVE_MAX_W * 0.1, 1e-6))
|
||||
check(f"...and it is over §9.1's 80 % warning threshold",
|
||||
good_tr.warning_frac(B) > 0.8)
|
||||
|
||||
# A CORRUPT database file is the third way a store fails, and it must land in
|
||||
# NOT_OBSERVED too rather than raising into the control loop.
|
||||
corrupt_path = TMP / "corrupt.db"
|
||||
corrupt_path.write_bytes(b"this is not a database" * 64)
|
||||
corrupt = PeakStore(str(corrupt_path))
|
||||
c_r = PeakTracker(corrupt, cap_w=LIVE_MAX_W).peak(B)
|
||||
print(f" a corrupt database file -> {c_r.state}")
|
||||
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
|
||||
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. the timezone fallback, graded against zoneinfo")
|
||||
|
||||
_zi = None
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
_zi = ZoneInfo("Europe/Brussels")
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f" zoneinfo unavailable here ({err}) - the fallback IS the tz")
|
||||
|
||||
if _zi is None:
|
||||
check("zoneinfo is absent, so the encoded rule cannot be graded here - "
|
||||
"reported, not silently passed", True)
|
||||
else:
|
||||
fb = _EUTimezone()
|
||||
# ⚠️ HOURLY, NOT 3-HOURLY, ACROSS THE TRANSITION DAYS. Both EU transitions
|
||||
# happen at 01:00 UTC, so a rule that is wrong BY AN HOUR differs from the
|
||||
# right one only between 01:00 and 02:00 UTC. A 3-hourly sweep steps over
|
||||
# that window entirely and grades nothing - measured: a mutation moving the
|
||||
# UTC threshold from 01:00 to 02:00 survived the whole file at 3-hourly.
|
||||
probes = [datetime(2026, 1, 1, 12, tzinfo=timezone.utc)]
|
||||
probes += [datetime(2026, m, d, h, mi, tzinfo=timezone.utc)
|
||||
for m in (3, 10) for d in (28, 29, 30, 31)
|
||||
for h in range(24) for mi in (0, 30)]
|
||||
probes += [datetime(2026, m, 1, 0, 30, tzinfo=timezone.utc) for m in range(1, 13)]
|
||||
# ⚠️ COMPARE THE CONVERTED WALL CLOCK, NOT ITS utcoffset(). `astimezone`
|
||||
# goes through `fromutc`, but the RESULT carries local wall values, so
|
||||
# asking it for `.utcoffset()` routes straight back into the LOCAL arm and
|
||||
# never grades `fromutc` at all. Measured: a mutation moving the UTC
|
||||
# threshold by an hour survived 397 offset-comparing probes and dies
|
||||
# immediately on the wall clock they produce.
|
||||
def _wallclock(t, tz):
|
||||
return t.astimezone(tz).replace(tzinfo=None)
|
||||
|
||||
bad = [t for t in probes if _wallclock(t, fb) != _wallclock(t, _zi)]
|
||||
print(f" {len(probes)} probes across both DST transitions and every "
|
||||
f"month boundary: {len(bad)} disagreement(s) in the LOCAL WALL CLOCK "
|
||||
f"each one converts to")
|
||||
check("the encoded EU rule converts every probe to the same local wall "
|
||||
"clock as zoneinfo, including the two transition weekends", not bad)
|
||||
# ...and the probes are not all the same offset, or the check is vacuous.
|
||||
offsets = {t.astimezone(_zi).utcoffset() for t in probes}
|
||||
check(f"...and the probe set spans BOTH offsets ({sorted(str(o) for o in offsets)}), "
|
||||
f"so agreeing is not free", len(offsets) == 2)
|
||||
check(f"BILLING_TZ resolved to zoneinfo on this host, not the fallback",
|
||||
not isinstance(BILLING_TZ, _EUTimezone))
|
||||
|
||||
# ⚠️ THE PROBES ABOVE ONLY EXERCISE `fromutc()`. `astimezone` on a UTC
|
||||
# instant never calls `utcoffset()` with a local wall time, so the local
|
||||
# arm of the rule was ungraded - a mutation that moved it by an hour
|
||||
# survived the whole file. These probes drive it directly. Noon is used
|
||||
# because it is unambiguous on every day of the year, including the two
|
||||
# transition days where 02:00-03:00 local is missing or repeated.
|
||||
#
|
||||
# ⚠️ AND THE LOCAL PROBES MUST INCLUDE 03:00-03:59 ON THE OCTOBER SUNDAY.
|
||||
# That hour is where a local rule that ends DST an hour late still claims
|
||||
# CEST while zoneinfo has already returned to CET; at noon both answers
|
||||
# agree and the probe is worth nothing. Measured: at noon-only, a mutation
|
||||
# moving the local thresholds from 02:00/03:00 to 03:00/04:00 survived.
|
||||
#
|
||||
# The MARCH gap (02:00-02:59 local, which does not exist) is deliberately
|
||||
# NOT probed: Python hands a nonexistent local time to `utcoffset` as an
|
||||
# ordinary value and any answer is defensible, so grading it would pin an
|
||||
# arbitrary choice. It cannot move a month boundary - it is the last Sunday
|
||||
# of March, never the 1st of a month - which is the only thing this tz is
|
||||
# used for.
|
||||
wall = [datetime(2026, m, d, 12) for m, d in
|
||||
[(3, 27), (3, 28), (3, 29), (3, 30), (10, 24), (10, 25),
|
||||
(10, 26), (10, 27)]]
|
||||
wall += [datetime(2026, 10, 25, h, mi) for h in range(0, 6) for mi in (0, 30)
|
||||
if h != 2] # 02:00 local is the ambiguous fold
|
||||
wall += [datetime(2026, 3, 29, h, mi) for h in (0, 1, 3, 4, 5) for mi in (0, 30)]
|
||||
wall += [datetime(2026, m, 1, 12) for m in range(1, 13)]
|
||||
bad_w = [w for w in wall
|
||||
if w.replace(tzinfo=fb).utcoffset() != w.replace(tzinfo=_zi).utcoffset()]
|
||||
w_offsets = {w.replace(tzinfo=_zi).utcoffset() for w in wall}
|
||||
print(f" {len(wall)} LOCAL-wall probes through utcoffset(): "
|
||||
f"{len(bad_w)} disagreement(s), spanning "
|
||||
f"{sorted(str(o) for o in w_offsets)}")
|
||||
check("utcoffset() on local wall time matches zoneinfo across both "
|
||||
"transition weekends - the arm astimezone() never reaches", not bad_w)
|
||||
check("...and those probes span both offsets too", len(w_offsets) == 2)
|
||||
bad_n = [w for w in wall
|
||||
if w.replace(tzinfo=fb).tzname() != w.replace(tzinfo=_zi).tzname()]
|
||||
check("...and tzname() agrees with zoneinfo at every one of them (CET/CEST)",
|
||||
not bad_n)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. the per-phase reduction, on an UNBALANCED three-phase load (§17)")
|
||||
|
||||
# One phase exporting is what separates the three policies. On a balanced load
|
||||
# they all give the same number, which is why this fixture is not balanced.
|
||||
PH = (3000.0, 500.0, -200.0)
|
||||
NET = sum(PH)
|
||||
u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
||||
u_closed = []
|
||||
for i in range(0, QUARTER_S + 1, 5):
|
||||
u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH))
|
||||
blk = u_closed[0]
|
||||
vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
||||
print(f" phases {PH} (net {NET:+.0f} W) -> "
|
||||
+ ", ".join(f"{k}={v:.0f} W" for k, v in vals.items()))
|
||||
check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)",
|
||||
near(vals[POLICY_NET], NET, 1e-6))
|
||||
check(f"the SUM policy clips each phase first, so the exporting phase does not "
|
||||
f"net off ({sum(max(v, 0.0) for v in PH):.0f} W)",
|
||||
near(vals[POLICY_SUM], sum(max(v, 0.0) for v in PH), 1e-6))
|
||||
check(f"the MAX policy bills the worst single phase ({max(PH):.0f} W)",
|
||||
near(vals[POLICY_MAX], max(PH), 1e-6))
|
||||
check("...and on THIS fixture all three are different numbers - which is the "
|
||||
"whole reason the reduction is a named policy and not an assumption",
|
||||
len({round(v, 6) for v in vals.values()}) == 3)
|
||||
check("the shipped default is NET, the meter's own billed register",
|
||||
billed_w(blk) == vals[POLICY_NET])
|
||||
|
||||
bal = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
||||
b_closed = []
|
||||
for i in range(0, QUARTER_S + 1, 5):
|
||||
b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3,
|
||||
phase_net=(1000.0, 1000.0, 1000.0)))
|
||||
b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
||||
check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), "
|
||||
f"so a balanced fixture could not have told them apart",
|
||||
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8. monthly_peak is DERIVED, and provably has not diverged")
|
||||
|
||||
st8 = fresh_store("derive")
|
||||
QUARTERS = [(0, 1200.0), (1, 4998.0), (2, 3357.0), (3, 800.0), (4, 4832.0)]
|
||||
for q, w in QUARTERS:
|
||||
st8.record_block(_CB(B + timedelta(seconds=q * QUARTER_S), w, None))
|
||||
MAXQ = max(w for _, w in QUARTERS)
|
||||
stored = st8.peak(month=month_key(B))
|
||||
derived = st8.recompute_month(month_key(B))
|
||||
print(f" {len(QUARTERS)} quarters banked; stored peak {stored.peak_w:.0f} W, "
|
||||
f"recomputed from quarter_blocks {derived.peak_w:.0f} W")
|
||||
check(f"the stored monthly peak is the largest quarter ({MAXQ:.0f} W - "
|
||||
f"specs.md:583's own figure)",
|
||||
stored.is_observed and near(stored.peak_w, MAXQ))
|
||||
check("...and re-deriving it from the 15-min tier gives the same number and "
|
||||
"the same quarter, so the two records are proven equal rather than "
|
||||
"assumed to be",
|
||||
derived.is_observed and near(derived.peak_w, stored.peak_w)
|
||||
and derived.quarter_start == stored.quarter_start)
|
||||
check("...and the two measured regressions from §9.1 land on DIFFERENT peaks - "
|
||||
f"the sustained {MAXQ:.0f} W quarter, not the oscillating 3357 W one",
|
||||
not near(stored.peak_w, 3357.0))
|
||||
# A quarter arriving LATER and lower must not lower the month.
|
||||
st8.record_block(_CB(B + timedelta(seconds=9 * QUARTER_S), 100.0, None))
|
||||
check("a later, smaller quarter does not lower the month's peak",
|
||||
near(st8.peak(month=month_key(B)).peak_w, MAXQ))
|
||||
check("...and the recompute agrees with it after that write, too",
|
||||
near(st8.recompute_month(month_key(B)).peak_w, MAXQ))
|
||||
# No pruning: §15 says these two tiers are indefinite / permanent.
|
||||
src = (HERE / "app" / "tariff.py").read_text(encoding="utf-8")
|
||||
check("§15's retention is structural: no DELETE against quarter_blocks or "
|
||||
"monthly_peak exists anywhere in tariff.py",
|
||||
"DELETE FROM quarter_blocks" not in src
|
||||
and "DELETE FROM monthly_peak" not in src
|
||||
and "DROP TABLE" not in src)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. AC 3, on the replay data that actually exists")
|
||||
|
||||
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
||||
print(f" replay fixtures found: {len(REPLAY)} "
|
||||
f"({', '.join(p.name for p in REPLAY) or 'none'})")
|
||||
check("the committed replay fixtures are present - a regression fixture CI "
|
||||
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
||||
|
||||
# ⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with the
|
||||
# default, once under `schema:` with `int(500,15000)`. Taking the last match
|
||||
# would blow up on the schema line, and taking any match that parses would be
|
||||
# luck; the FIRST is the options block, which is the number an install lands on.
|
||||
SHIPPED_CAP_W = None
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("peak_cap_w:"):
|
||||
try:
|
||||
SHIPPED_CAP_W = float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
check("config.yaml's shipped peak_cap_w was read out of the file rather than "
|
||||
"typed here", SHIPPED_CAP_W is not None)
|
||||
|
||||
replay_days = 0
|
||||
total_q = 0
|
||||
under = 0
|
||||
for path in REPLAY:
|
||||
st9 = fresh_store("replay-" + path.stem)
|
||||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_MAX_W)
|
||||
first = last = None
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
first = first or t
|
||||
last = t
|
||||
ing.submit(sample(t, float(row["w"])))
|
||||
tr9.sync(ing)
|
||||
months = {month_key(b.start) for b in ing.blocks}
|
||||
span_h = (last - first).total_seconds() / 3600.0
|
||||
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
|
||||
peak = max(qs) if qs else 0.0
|
||||
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_MAX_W))
|
||||
total_q += len(qs)
|
||||
under += ok_q
|
||||
replay_days += span_h / 24.0
|
||||
print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, "
|
||||
f"month peak {peak:.0f} W, "
|
||||
f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the "
|
||||
f"{SHIPPED_CAP_W:.0f} W soft limit")
|
||||
check(f"{path.name}: the month's peak equals its own largest quarter",
|
||||
near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
|
||||
check(f"{path.name}: a full day of real telegrams produces ~96 quarters, "
|
||||
f"got {len(qs)}", 90 <= len(qs) <= 96)
|
||||
|
||||
PCT = 100.0 * under / max(total_q, 1)
|
||||
print()
|
||||
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
|
||||
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
|
||||
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
|
||||
print(f" AC 3 ASKS FOR 30 DAYS and this run is {replay_days:.1f}, so it "
|
||||
f"does not settle it either way. The 30 days DO now exist - "
|
||||
f"scenarios/p1-net-30d-2026-08/, DATA-01 - and "
|
||||
f"tools/replay_ac3.py is what runs them; the figure above is the "
|
||||
f"BASELINE SAFETY-07 has to improve, on the two days CI replays.")
|
||||
check("the replay produced a measurable number over a stated sample size, "
|
||||
"rather than a criterion quietly redefined to fit the data",
|
||||
total_q > 100 and replay_days < 30)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. the wiring: the real Controller, and what its status page says")
|
||||
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
async def limits(self, entity):
|
||||
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
def built(db_path, opts=None):
|
||||
return Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15}, **(opts or {})),
|
||||
_Hass(), _Store(), _Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=str(db_path))
|
||||
|
||||
|
||||
c = built(TMP / "wired.db")
|
||||
check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED "
|
||||
f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot "
|
||||
f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W)
|
||||
|
||||
# Drive one real quarter through the real ingest, then the real cycle's sync.
|
||||
for i in range(0, QUARTER_S + 1, 5):
|
||||
c.p1.submit(sample(B + timedelta(seconds=i), 4998.0))
|
||||
n = c.peak.sync(c.p1)
|
||||
wr = c.peak.peak(B)
|
||||
print(f" after one 4998 W quarter through the real ingest: "
|
||||
f"{n} banked, {wr.explain()}")
|
||||
check("the closed quarter reached SQLite through the tracker the cycle calls",
|
||||
n == 1 and wr.is_observed and near(wr.peak_w, 4998.0, 1e-6))
|
||||
|
||||
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
|
||||
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
|
||||
# same row per block, and `recorded` - the number the status page prints as
|
||||
# "quarters banked" - would be a count of loop iterations rather than of
|
||||
# quarters. The database stays correct either way, which is exactly why this
|
||||
# needs its own check instead of being caught by the peak.
|
||||
again = c.peak.sync(c.p1)
|
||||
third = c.peak.sync(c.p1)
|
||||
print(f" sync() called three times over the same closed quarter: "
|
||||
f"{n}, {again}, {third} newly banked; tracker reports "
|
||||
f"{c.peak.recorded} quarter(s)")
|
||||
check("...and syncing the same quarter again banks nothing new, so the count "
|
||||
"the status page shows is quarters and not cycles",
|
||||
again == 0 and third == 0 and c.peak.recorded == 1)
|
||||
check("...and the peak is unchanged by the repeat",
|
||||
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
||||
|
||||
rows = [r for r in c.checks() if "capacity-tariff" in r["text"] or "peak" in r["text"]]
|
||||
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
|
||||
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||
check("the status page carries exactly one capacity-tariff peak row",
|
||||
len(peak_rows) == 1)
|
||||
check("...naming the peak, the soft limit and the quarters banked, all derived "
|
||||
"from the controller that was actually built",
|
||||
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
|
||||
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
|
||||
and "1 quarter(s) banked" in peak_rows[0]["text"])
|
||||
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||
f"on a {SHIPPED_CAP_W:.0f} W limit",
|
||||
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
||||
|
||||
# The half that matters: the same page with a store that cannot answer.
|
||||
c2 = built(TMP / "no-such-dir" / "nope.db")
|
||||
u_rows = [r for r in c2.checks() if "capacity-tariff peak" in r["text"]]
|
||||
print(" status row (unreadable store): "
|
||||
+ (u_rows[0]["text"] if u_rows else "(none)"))
|
||||
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
|
||||
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
|
||||
"instrument looks exactly like a month with no peak",
|
||||
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
|
||||
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
|
||||
check("...and the healthy row and the unreadable row are different text",
|
||||
bool(u_rows) and bool(peak_rows)
|
||||
and u_rows[0]["text"] != peak_rows[0]["text"])
|
||||
|
||||
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
|
||||
# SYNTAX, not on the text: the module docstring argues this at length and names
|
||||
# every forbidden word while doing so, so a grep over the source would be
|
||||
# satisfied by deleting the explanation and would fail on writing one. What must
|
||||
# be true is that no NAME the code evaluates refers to a command.
|
||||
import ast as _ast
|
||||
|
||||
_tree = _ast.parse(src)
|
||||
_names = {n.id for n in _ast.walk(_tree) if isinstance(n, _ast.Name)}
|
||||
_names |= {n.attr for n in _ast.walk(_tree) if isinstance(n, _ast.Attribute)}
|
||||
_names |= {n.name for n in _ast.walk(_tree)
|
||||
if isinstance(n, (_ast.FunctionDef, _ast.ClassDef))}
|
||||
_FORBIDDEN = {"setpoint", "write_setpoint", "target", "Decision", "i_clamped",
|
||||
"clamped", "Claim", "resolve", "P_PEAK", "arbiter"}
|
||||
_found = sorted(_names & _FORBIDDEN)
|
||||
_imports = sorted(m.module or "" for m in _ast.walk(_tree)
|
||||
if isinstance(m, _ast.ImportFrom))
|
||||
print(f" tariff.py evaluates {len(_names)} distinct names and imports "
|
||||
f"from {_imports or ['(stdlib only)']}")
|
||||
check(f"none of them is a command-side name {sorted(_FORBIDDEN)} - the utility "
|
||||
f"bills what FLOWED, so this reads the meter and nothing else"
|
||||
+ (f"; found {_found}" if _found else ""), not _found)
|
||||
check("...and it imports nothing from arbiter, clamp or control: it files no "
|
||||
"claim, applies no clamp, and commands nothing - SAFETY-07's job",
|
||||
not [m for m in _imports if m in ("arbiter", "clamp", "control",
|
||||
".arbiter", ".clamp", ".control")])
|
||||
# ...and the guard is not vacuous: the same grader run over main.py, which DOES
|
||||
# command, must light up.
|
||||
_main = {n.attr for n in _ast.walk(
|
||||
_ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")))
|
||||
if isinstance(n, _ast.Attribute)}
|
||||
check(f"the same grader over main.py finds {sorted(_main & _FORBIDDEN)}, so it "
|
||||
f"is capable of failing", bool(_main & _FORBIDDEN))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a P1 power signal out of Home Assistant's recorder into the committed
|
||||
scenario format - one gzipped JSON object per line, one file per LOCAL day.
|
||||
|
||||
WHY THIS FILE EXISTS AT ALL
|
||||
===========================
|
||||
`scenarios/p1-net-2026-08-20.jsonl.gz` and `p1-net-2026-08-23.jsonl.gz` were
|
||||
produced once, by hand, and the script was thrown away. The consequence was not
|
||||
theoretical: TARIFF-01's and SAFETY-07's AC 3 ("monthly peak held under the soft
|
||||
limit in >= 95 % of intervals over a 30-day simulation replay") were both merged
|
||||
UNMET on the belief that thirty days could not be produced retroactively - a
|
||||
belief that was simply wrong, and that survived because nobody could re-run the
|
||||
export to find out. So the export is a committed, runnable artefact now, and the
|
||||
next person does not have to re-derive it under time pressure while a retention
|
||||
window rolls.
|
||||
|
||||
python3 tools/export_p1_history.py --days 30 --out-dir /some/where
|
||||
|
||||
THE TWO RULES THIS FILE IS BUILT AROUND
|
||||
=======================================
|
||||
1. A FAILED READ IS NOT AN OBSERVATION. An HTTP error, a timeout, a truncated
|
||||
body, a non-JSON body - none of those are "the meter reported nothing that
|
||||
hour". They are "we do not know what the meter reported that hour", and the
|
||||
two must never end up looking the same in the output. So:
|
||||
|
||||
* every request is retried with backoff;
|
||||
* a day whose read ultimately fails writes NO FILE and is recorded in the
|
||||
manifest as `"read": "FAILED"` with the error text, and the process exits
|
||||
non-zero;
|
||||
* a day that reads back successfully but EMPTY writes an empty file and is
|
||||
recorded as `"read": "OK", "samples": 0`. Empty-and-known and
|
||||
unknown are therefore distinguishable both in the manifest and by the
|
||||
presence or absence of the file;
|
||||
* before any day is fetched, the entity is proved to exist through
|
||||
/api/states/<entity>. Without that check a typo in --entity would return
|
||||
200 + `[]` for all thirty days and produce a beautifully consistent
|
||||
dataset of nothing.
|
||||
|
||||
2. GAPS ARE VISIBLE, NEVER INTERPOLATED. Nothing here carries a value forward,
|
||||
fills, resamples or averages. A recorder outage or an HA restart shows up as
|
||||
what it is: consecutive `t` values further apart than the meter's cadence.
|
||||
The controller's own ingest already reads that correctly - `meter_max_age_s`
|
||||
(30 s shipped) is exactly the age past which a held reading stops counting as
|
||||
coverage - so an hour with no telegrams replays as an hour of NO COVERAGE
|
||||
rather than as an hour of whatever the last sample said. The manifest lists
|
||||
every gap over --gap-s so a reader does not have to diff timestamps to find
|
||||
them.
|
||||
|
||||
DAY AT A TIME, AND WHY NOT SMALLER
|
||||
==================================
|
||||
One request per local day. That is gentle on a live HA (~1 MB of JSON per day)
|
||||
and it is also the only chunking that reproduces the committed files: the
|
||||
history API prepends the state as it stood AT the period start
|
||||
(`include_start_time_state`), stamped with the start instant. Splitting a day
|
||||
into six-hour chunks would therefore inject three synthetic boundary samples per
|
||||
day that no meter ever sent. Days are the natural boundary because the committed
|
||||
files are local days.
|
||||
|
||||
VERIFYING THE EXPORTER
|
||||
======================
|
||||
python3 tools/export_p1_history.py --verify scenarios/p1-net-2026-08-20.jsonl.gz
|
||||
|
||||
re-fetches that day by this exact route and compares the DECOMPRESSED bytes with
|
||||
the committed file. Byte equality of the .gz containers is not expected and not
|
||||
required: a gzip header carries an mtime, and this file writes mtime 0 so its
|
||||
own output is reproducible. Content equality is the claim that matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try: # py3.9+; the add-on image has it
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError: # pragma: no cover
|
||||
ZoneInfo = None # type: ignore
|
||||
|
||||
DEFAULT_URL = "http://192.168.2.158:8123"
|
||||
DEFAULT_ENTITY = "sensor.p1_meter_active_power"
|
||||
DEFAULT_TZ = "Europe/Brussels"
|
||||
# The shipped meter_max_age_s. A hole longer than this is a hole the controller
|
||||
# itself would refuse to see through, which makes it the honest gap threshold.
|
||||
DEFAULT_GAP_S = 30.0
|
||||
|
||||
|
||||
class ReadFailed(RuntimeError):
|
||||
"""The read did not happen. NOT the same thing as an empty result."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transport
|
||||
|
||||
|
||||
def _get(url: str, token: str, timeout: float):
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
raise ReadFailed(f"HTTP {resp.status}")
|
||||
return resp.read()
|
||||
|
||||
|
||||
def fetch_json(url: str, token: str, *, timeout: float, retries: int,
|
||||
backoff: float, log=print):
|
||||
"""GET and parse, or raise ReadFailed. Never returns a partial answer."""
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
raw = _get(url, token, timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
last = f"HTTP {exc.code} {exc.reason}"
|
||||
except Exception as exc: # URLError, timeout, ...
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
else:
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8")), len(raw), attempt
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
# A truncated body looks exactly like this. It is a failure.
|
||||
last = f"unparseable body ({len(raw)} bytes): {exc}"
|
||||
if attempt < retries:
|
||||
wait = backoff * attempt
|
||||
log(f" read failed ({last}); retry {attempt + 1}/{retries} "
|
||||
f"in {wait:.0f} s")
|
||||
time.sleep(wait)
|
||||
raise ReadFailed(last or "unknown")
|
||||
|
||||
|
||||
def assert_entity_exists(base: str, entity: str, token: str, *, timeout: float,
|
||||
retries: int, backoff: float, log=print):
|
||||
"""Prove the entity is real BEFORE trusting any empty day.
|
||||
|
||||
Without this, `--entity sensor.typo` answers 200 + [] for every day and the
|
||||
exporter cheerfully writes thirty empty files.
|
||||
"""
|
||||
url = f"{base}/api/states/{urllib.parse.quote(entity)}"
|
||||
doc, _, _ = fetch_json(url, token, timeout=timeout, retries=retries,
|
||||
backoff=backoff, log=log)
|
||||
if not isinstance(doc, dict) or doc.get("entity_id") != entity:
|
||||
raise ReadFailed(f"{entity} is not a known entity")
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one day
|
||||
|
||||
|
||||
def day_bounds(day: datetime, tz):
|
||||
start = datetime(day.year, day.month, day.day, tzinfo=tz)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def history_url(base: str, entity: str, start: datetime, end: datetime) -> str:
|
||||
q = urllib.parse.urlencode({
|
||||
"filter_entity_id": entity,
|
||||
"end_time": end.isoformat(),
|
||||
"minimal_response": "",
|
||||
})
|
||||
return (f"{base}/api/history/period/"
|
||||
f"{urllib.parse.quote(start.isoformat())}?{q}")
|
||||
|
||||
|
||||
def rows_from(doc, entity: str):
|
||||
"""HA's shape -> [(iso_t, state_str)], plus the states that are not numbers.
|
||||
|
||||
`minimal_response` returns the first record in full and the rest as
|
||||
{state, last_changed}. An entity that went unavailable reports the string
|
||||
"unavailable" (or "unknown") where a number belongs. Those are dropped from
|
||||
the numeric series - there is no float to write and the committed schema has
|
||||
no slot for a non-number - but every one is returned here so it lands in the
|
||||
manifest. Dropping them is what MAKES the outage visible: the series simply
|
||||
has no samples across it, which is the same thing the meter falling silent
|
||||
looks like, and is what the controller's freshness rule already handles.
|
||||
"""
|
||||
if not isinstance(doc, list):
|
||||
raise ReadFailed(f"expected a list, got {type(doc).__name__}")
|
||||
if not doc:
|
||||
return [], []
|
||||
series = doc[0]
|
||||
if not isinstance(series, list):
|
||||
raise ReadFailed("history series is not a list")
|
||||
good, bad = [], []
|
||||
for rec in series:
|
||||
eid = rec.get("entity_id")
|
||||
if eid is not None and eid != entity:
|
||||
raise ReadFailed(f"unexpected entity {eid!r} in the response")
|
||||
t = rec.get("last_changed") or rec.get("last_updated")
|
||||
s = rec.get("state")
|
||||
if t is None:
|
||||
raise ReadFailed("a record carries no timestamp")
|
||||
try:
|
||||
w = float(s)
|
||||
except (TypeError, ValueError):
|
||||
bad.append({"t": t, "state": s})
|
||||
continue
|
||||
good.append((t, w))
|
||||
return good, bad
|
||||
|
||||
|
||||
def render(rows, *, newline: str) -> bytes:
|
||||
"""The committed format, exactly: `{"t": <iso>, "w": <float>}` per line.
|
||||
|
||||
json.dumps' default separators (", " / ": ") and repr of a float are what
|
||||
the two committed files contain; so is the CRLF, an artefact of the original
|
||||
one-off having been run on Windows in text mode. It is reproduced rather
|
||||
than tidied because reproducing the committed bytes is this exporter's
|
||||
verification, and gzip.open(..., 'rt') strips it either way.
|
||||
"""
|
||||
out = bytearray()
|
||||
for t, w in rows:
|
||||
out += json.dumps({"t": t, "w": w}).encode("utf-8")
|
||||
out += newline.encode("ascii")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def gz(payload: bytes) -> bytes:
|
||||
"""gzip level 9, mtime 0 - so the same input always gives the same bytes."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=9, mtime=0) as f:
|
||||
f.write(payload)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def gaps_in(rows, gap_s: float):
|
||||
out = []
|
||||
prev = None
|
||||
for t, _ in rows:
|
||||
cur = datetime.fromisoformat(t)
|
||||
if prev is not None:
|
||||
d = (cur - prev).total_seconds()
|
||||
if d > gap_s:
|
||||
out.append({"from": prev.isoformat(), "to": cur.isoformat(),
|
||||
"seconds": round(d, 3)})
|
||||
prev = cur
|
||||
return out
|
||||
|
||||
|
||||
def export_day(base, entity, token, day, tz, *, timeout, retries, backoff,
|
||||
gap_s, newline, log=print):
|
||||
"""Read one local day. Raises ReadFailed rather than returning a guess."""
|
||||
start, end = day_bounds(day, tz)
|
||||
doc, nbytes, attempts = fetch_json(
|
||||
history_url(base, entity, start, end), token,
|
||||
timeout=timeout, retries=retries, backoff=backoff, log=log)
|
||||
rows, bad = rows_from(doc, entity)
|
||||
entry = {
|
||||
"date": day.date().isoformat(),
|
||||
"window": [start.isoformat(), end.isoformat()],
|
||||
"read": "OK",
|
||||
"http_bytes": nbytes,
|
||||
"attempts": attempts,
|
||||
"samples": len(rows),
|
||||
"non_numeric_states": bad,
|
||||
"first": rows[0][0] if rows else None,
|
||||
"last": rows[-1][0] if rows else None,
|
||||
"gaps": gaps_in(rows, gap_s),
|
||||
}
|
||||
if rows:
|
||||
span = (datetime.fromisoformat(rows[-1][0])
|
||||
- datetime.fromisoformat(rows[0][0])).total_seconds()
|
||||
entry["covered_span_s"] = round(span, 1)
|
||||
entry["mean_cadence_s"] = round(span / max(1, len(rows) - 1), 3)
|
||||
return rows, entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# modes
|
||||
|
||||
|
||||
def do_export(args, token, tz, log=print):
|
||||
base = args.url.rstrip("/")
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
newline = "\r\n" if args.newline == "crlf" else "\n"
|
||||
|
||||
log(f" proving {args.entity} exists before trusting any empty day")
|
||||
st = assert_entity_exists(base, args.entity, token, timeout=args.timeout,
|
||||
retries=args.retries, backoff=args.backoff,
|
||||
log=log)
|
||||
log(f" {args.entity} = {st.get('state')} "
|
||||
f"{st.get('attributes', {}).get('unit_of_measurement', '')}")
|
||||
|
||||
if args.end_date:
|
||||
last = datetime.fromisoformat(args.end_date).replace(tzinfo=tz)
|
||||
else: # yesterday: today is still being logged
|
||||
last = datetime.now(tz).replace(hour=0, minute=0, second=0,
|
||||
microsecond=0) - timedelta(days=1)
|
||||
days = [last - timedelta(days=i) for i in range(args.days - 1, -1, -1)]
|
||||
|
||||
manifest = {
|
||||
"generated": datetime.now(timezone.utc).isoformat(),
|
||||
"generator": "tools/export_p1_history.py",
|
||||
"source": f"{base}/api/history/period",
|
||||
"entity": args.entity,
|
||||
"timezone": args.tz,
|
||||
"gap_threshold_s": args.gap_s,
|
||||
"format": '{"t": <ISO-8601 UTC>, "w": <float watts>} per line, gzip',
|
||||
"days": [],
|
||||
}
|
||||
failures = []
|
||||
for day in days:
|
||||
name = f"{args.prefix}{day.date().isoformat()}.jsonl.gz"
|
||||
log(f" {name}")
|
||||
try:
|
||||
rows, entry = export_day(
|
||||
base, args.entity, token, day, tz, timeout=args.timeout,
|
||||
retries=args.retries, backoff=args.backoff, gap_s=args.gap_s,
|
||||
newline=newline, log=log)
|
||||
except ReadFailed as exc:
|
||||
# NO FILE. A missing file is the honest record of a read that did
|
||||
# not happen; an empty one would claim the meter was silent.
|
||||
entry = {"date": day.date().isoformat(), "read": "FAILED",
|
||||
"error": str(exc), "file": None}
|
||||
manifest["days"].append(entry)
|
||||
failures.append(entry)
|
||||
log(f" READ FAILED: {exc} - no file written")
|
||||
continue
|
||||
payload = render(rows, newline=newline)
|
||||
blob = gz(payload)
|
||||
(out_dir / name).write_bytes(blob)
|
||||
entry["file"] = name
|
||||
entry["bytes_gz"] = len(blob)
|
||||
entry["bytes_raw"] = len(payload)
|
||||
entry["sha256_raw"] = hashlib.sha256(payload).hexdigest()
|
||||
entry["sha256_gz"] = hashlib.sha256(blob).hexdigest()
|
||||
manifest["days"].append(entry)
|
||||
ng = len(entry.get("gaps", []))
|
||||
log(f" {entry['samples']} samples, {len(blob)} B gz, "
|
||||
f"{ng} gap(s) over {args.gap_s:g} s"
|
||||
+ (f", {len(entry['non_numeric_states'])} non-numeric state(s)"
|
||||
if entry["non_numeric_states"] else ""))
|
||||
if day is not days[-1]:
|
||||
time.sleep(args.delay)
|
||||
|
||||
ok = [d for d in manifest["days"] if d["read"] == "OK"]
|
||||
manifest["summary"] = {
|
||||
"days_requested": len(days),
|
||||
"days_read": len(ok),
|
||||
"days_failed": len(failures),
|
||||
"samples": sum(d["samples"] for d in ok),
|
||||
"bytes_gz": sum(d["bytes_gz"] for d in ok),
|
||||
"empty_days": [d["date"] for d in ok if d["samples"] == 0],
|
||||
}
|
||||
# newline="\n" explicitly: write_text() on Windows emits CRLF, and
|
||||
# the manifest would then differ from a Linux run of the same export
|
||||
# for no reason visible in a diff.
|
||||
with open(args.manifest or (out_dir / "manifest.json"), "w",
|
||||
encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(json.dumps(manifest, indent=2) + "\n")
|
||||
log(f" {manifest['summary']['days_read']}/{len(days)} days, "
|
||||
f"{manifest['summary']['samples']} samples, "
|
||||
f"{manifest['summary']['bytes_gz'] / 1e6:.2f} MB gz")
|
||||
if failures:
|
||||
log(f" {len(failures)} DAY(S) NOT READ - the dataset is incomplete "
|
||||
f"and says so: " + ", ".join(d["date"] for d in failures))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def do_verify(args, token, tz, log=print):
|
||||
"""Re-export a committed day by this route and diff it against the file."""
|
||||
base = args.url.rstrip("/")
|
||||
newline = "\r\n" if args.newline == "crlf" else "\n"
|
||||
rc = 0
|
||||
for target in args.verify:
|
||||
path = Path(target)
|
||||
stamp = path.name.split(".")[0].rsplit("-", 3)[-3:]
|
||||
day = datetime(int(stamp[0]), int(stamp[1]), int(stamp[2]), tzinfo=tz)
|
||||
log(f" {path.name} (local day {day.date().isoformat()})")
|
||||
committed = gzip.decompress(path.read_bytes())
|
||||
rows, entry = export_day(base, args.entity, token, day, tz,
|
||||
timeout=args.timeout, retries=args.retries,
|
||||
backoff=args.backoff, gap_s=args.gap_s,
|
||||
newline=newline, log=log)
|
||||
fresh = render(rows, newline=newline)
|
||||
if fresh == committed:
|
||||
log(f" IDENTICAL: {len(fresh)} bytes, {entry['samples']} "
|
||||
f"samples, sha256 {hashlib.sha256(fresh).hexdigest()[:16]}")
|
||||
continue
|
||||
rc = 1
|
||||
cl = committed.split(newline.encode())
|
||||
fl = fresh.split(newline.encode())
|
||||
log(f" DIFFERS: committed {len(committed)} B / {len(cl)} lines, "
|
||||
f"re-export {len(fresh)} B / {len(fl)} lines")
|
||||
shown = 0
|
||||
for i in range(max(len(cl), len(fl))):
|
||||
a = cl[i] if i < len(cl) else b"<absent>"
|
||||
b = fl[i] if i < len(fl) else b"<absent>"
|
||||
if a != b:
|
||||
log(f" line {i}: committed {a!r}")
|
||||
log(f" re-export {b!r}")
|
||||
shown += 1
|
||||
if shown >= args.max_diff:
|
||||
log(f" ... stopping after {shown} differing lines")
|
||||
break
|
||||
return rc
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("--url", default=DEFAULT_URL)
|
||||
p.add_argument("--entity", default=DEFAULT_ENTITY)
|
||||
p.add_argument("--token", help="bearer token; else $HASS_TOKEN")
|
||||
p.add_argument("--tz", default=DEFAULT_TZ,
|
||||
help="local timezone that defines a day boundary")
|
||||
p.add_argument("--days", type=int, default=30)
|
||||
p.add_argument("--end-date", help="last local day to export (YYYY-MM-DD); "
|
||||
"default yesterday")
|
||||
p.add_argument("--out-dir", default=".")
|
||||
p.add_argument("--prefix", default="p1-net-")
|
||||
p.add_argument("--manifest", help="path for the manifest JSON")
|
||||
p.add_argument("--gap-s", type=float, default=DEFAULT_GAP_S)
|
||||
p.add_argument("--newline", choices=("crlf", "lf"), default="crlf",
|
||||
help="line terminator; crlf reproduces the committed files")
|
||||
p.add_argument("--timeout", type=float, default=120.0)
|
||||
p.add_argument("--retries", type=int, default=4)
|
||||
p.add_argument("--backoff", type=float, default=5.0)
|
||||
p.add_argument("--delay", type=float, default=2.0,
|
||||
help="pause between days - this is a live HA")
|
||||
p.add_argument("--verify", nargs="+", metavar="FILE",
|
||||
help="re-export these committed days and diff them")
|
||||
p.add_argument("--max-diff", type=int, default=20)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
token = args.token or __import__("os").environ.get("HASS_TOKEN")
|
||||
if not token:
|
||||
print("no token: pass --token or set HASS_TOKEN", file=sys.stderr)
|
||||
return 2
|
||||
if ZoneInfo is None:
|
||||
print("zoneinfo unavailable", file=sys.stderr)
|
||||
return 2
|
||||
tz = ZoneInfo(args.tz)
|
||||
try:
|
||||
if args.verify:
|
||||
return do_verify(args, token, tz)
|
||||
return do_export(args, token, tz)
|
||||
except ReadFailed as exc:
|
||||
print(f"read failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run TARIFF-01's and SAFETY-07's AC 3 over a directory of `p1-net-*.jsonl.gz`.
|
||||
|
||||
AC 3, both cards, same words: *"monthly peak held under the soft limit in
|
||||
>= 95 % of intervals over a 30-day simulation replay."* Both cards merged with
|
||||
it UNMET because two days of capture existed. DATA-01 exported thirty. This is
|
||||
the thing that turns those thirty days into the number.
|
||||
|
||||
cd goodwe_controller
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
WHAT IT MEASURES
|
||||
================
|
||||
Two runs over the same telegrams, one file at a time in date order into a
|
||||
SHARED peak store, so the 15-minute blocks accumulate into a real MONTH rather
|
||||
than into 30 disconnected days:
|
||||
|
||||
UNCONTROLLED the recorded meter as it happened. Nothing was shaving peaks on
|
||||
this house during August 2026, so this is not a simulation of
|
||||
anything - it is what the month did. It is TARIFF-01's AC 3.
|
||||
GUARDED the same month with `peakguard.decide()` in the loop and the
|
||||
battery it asks for modelled, closed-loop: the meter the guard
|
||||
sees is the recorded net MINUS the discharge it obtained. That
|
||||
is SAFETY-07's AC 3.
|
||||
|
||||
Both numbers are `quarters <= peak_cap_w` over `quarters closed`, with the cap
|
||||
read out of config.yaml rather than typed. Nothing here exits non-zero on 95 %:
|
||||
a measurement below the criterion is a finding about the house and the guard,
|
||||
and this tool's job is to report it, not to be graded by it.
|
||||
|
||||
FAITHFULNESS TO THE SUITE
|
||||
=========================
|
||||
The replay loop is `test_peakguard.py` part 6's, with `pack=None` (SoC pinned
|
||||
above the floor, so SAFETY-24's energy floor is not the thing deciding the
|
||||
answer). It is not imported from there because that file runs its whole suite on
|
||||
import. The duplication is checked instead of asserted: pointed at `scenarios/`
|
||||
- the two committed days - this tool must reproduce part 6's published totals,
|
||||
and `--expect` makes that a hard check.
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
|
||||
GAPS
|
||||
====
|
||||
Nothing is interpolated. A hole in the export is a hole here: the shipped
|
||||
`meter_max_age_s` is what decides whether a held reading still counts as
|
||||
coverage, so an hour with no telegrams closes no quarters and contributes to
|
||||
neither numerator nor denominator. `--report-gaps` lists them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from app.p1 import P1Ingest, make_sample, split_signed # noqa: E402
|
||||
from app.peakguard import decide # noqa: E402
|
||||
from app.tariff import PeakStore, PeakTracker, month_key # noqa: E402
|
||||
|
||||
|
||||
def read_option(key: str):
|
||||
"""The shipped default for `key`, out of config.yaml.
|
||||
|
||||
⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with
|
||||
the default, once under `schema:` as `int(500,15000)`. The FIRST match is
|
||||
the options block, which is the number an install actually lands on; taking
|
||||
the last would try to float() the schema line. Same rule as the suites.
|
||||
"""
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith(f"{key}:"):
|
||||
try:
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def read_schema_max(key: str):
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith(f"{key}:") and "(" in s and ")" in s:
|
||||
try:
|
||||
return float(s[s.index("(") + 1:s.index(")")].split(",")[-1])
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def sample(t: datetime, net_w: float):
|
||||
"""One telegram through the SHIPPED sign derivation - the suites' helper."""
|
||||
imp, exp = split_signed(net_w)
|
||||
return make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=t, ingest_mono=t.timestamp())
|
||||
|
||||
|
||||
def rows(path):
|
||||
"""Every sample in one file, in order, with the gaps left as gaps."""
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
yield datetime.fromisoformat(row["t"]), float(row["w"])
|
||||
|
||||
|
||||
def replay(paths, *, guarded: bool, cap_w, max_w, max_hold_s, cycle_s,
|
||||
soc, soc_floor, tmp: Path, gap_s: float):
|
||||
"""test_peakguard.py part 6's loop, over an arbitrary number of days.
|
||||
|
||||
⚠️ ONE STORE, AND AN INGEST THAT IS RESET ONLY AT A REAL DISCONTINUITY.
|
||||
Neither half of that is a detail.
|
||||
|
||||
The STORE is shared across the files because "monthly peak" is what AC 3 is
|
||||
about and a per-file store cannot express it.
|
||||
|
||||
The INGEST is carried across a file boundary when the next file's first
|
||||
telegram follows the previous file's last one within `gap_s`, and rebuilt
|
||||
when it does not. Both failure modes are real and were measured here:
|
||||
|
||||
* carried across a genuine hole, one averager manufactures quarters out of
|
||||
nothing - streamed straight through the three-day gap between the two
|
||||
committed days this reported 285/286 where the suite reports 189/190;
|
||||
* rebuilt at every file, each day's LAST quarter (21:45-22:00 UTC) never
|
||||
closes, because it closes only when a telegram past the boundary
|
||||
arrives and that telegram is in the next file. That is why the suite's
|
||||
two days give 95 quarters each rather than 96, and it silently discards
|
||||
one quarter per day - 30 of them over a month.
|
||||
|
||||
So contiguous days are joined and non-contiguous ones are not, which gives
|
||||
189/190 on the committed pair and a full 96 quarters a day on a run of
|
||||
consecutive days.
|
||||
"""
|
||||
store = PeakStore(str(tmp / f"ac3-{'g' if guarded else 'u'}.db"))
|
||||
tracker = PeakTracker(store, cap_w=cap_w)
|
||||
engaged = blind = saturated = cycles = n = joins = 0
|
||||
wh = 0.0
|
||||
gaps = []
|
||||
months = set()
|
||||
ing = None
|
||||
batt = 0.0 # delivered discharge, W (positive)
|
||||
next_cycle = None
|
||||
prev_t = None
|
||||
for path in paths:
|
||||
for i, (t, w) in enumerate(rows(path)):
|
||||
if i == 0:
|
||||
contiguous = (prev_t is not None
|
||||
and 0 <= (t - prev_t).total_seconds() <= gap_s)
|
||||
if contiguous:
|
||||
joins += 1
|
||||
else:
|
||||
if ing is not None:
|
||||
months |= {month_key(b.start) for b in ing.blocks}
|
||||
ing = P1Ingest(phases=1, max_age_s=max_hold_s)
|
||||
batt = 0.0
|
||||
next_cycle = None
|
||||
prev_t = None
|
||||
if prev_t is not None:
|
||||
dt = (t - prev_t).total_seconds()
|
||||
if dt > gap_s:
|
||||
gaps.append((prev_t, t, dt))
|
||||
wh += batt * dt / 3600.0
|
||||
prev_t = t
|
||||
n += 1
|
||||
ing.submit(sample(t, w - batt))
|
||||
tracker.sync(ing)
|
||||
if next_cycle is not None and t < next_cycle:
|
||||
continue
|
||||
next_cycle = t + timedelta(seconds=cycle_s)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=cap_w, reading=tracker.peak(t),
|
||||
achievable_w=max_w, max_hold_s=max_hold_s, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
if d.engaged:
|
||||
blind += 1 if d.blind else 0
|
||||
engaged += 0 if d.blind else 1
|
||||
saturated += 1 if d.saturated else 0
|
||||
if not guarded:
|
||||
continue
|
||||
engaged_floor = d.engaged and d.floor_w is not None
|
||||
batt = max(0.0, d.floor_w) if engaged_floor else 0.0
|
||||
if ing is not None:
|
||||
months |= {month_key(b.start) for b in ing.blocks}
|
||||
out = {m: dict(store.blocks_in(m)) for m in sorted(months)}
|
||||
return {"months": out, "samples": n, "cycles": cycles, "joins": joins,
|
||||
"engaged": engaged, "blind": blind, "saturated": saturated,
|
||||
"wh": wh, "gaps": gaps}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("directory", help="a directory of p1-net-*.jsonl.gz")
|
||||
p.add_argument("--glob", default="p1-net-*.jsonl.gz")
|
||||
p.add_argument("--expect", help="UNDER/TOTAL the uncontrolled run must "
|
||||
"reproduce, e.g. 189/190")
|
||||
p.add_argument("--report-gaps", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
paths = sorted(Path(args.directory).glob(args.glob))
|
||||
if not paths:
|
||||
print(f"no {args.glob} under {args.directory}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
CAP_W = read_option("peak_cap_w")
|
||||
MAX_HOLD_S = read_option("meter_max_age_s")
|
||||
CYCLE_S = read_option("cycle_floor_s")
|
||||
SOC_FLOOR = read_option("peak_soc_floor")
|
||||
LIVE_MAX_W = read_schema_max("max_w")
|
||||
if None in (CAP_W, MAX_HOLD_S, CYCLE_S, SOC_FLOOR, LIVE_MAX_W):
|
||||
print("could not read the shipped options out of config.yaml",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
# The suites' fixture SoC: comfortably above the floor, so SAFETY-24's
|
||||
# energy floor is not what decides an AC 3 that is not about it.
|
||||
SOC_OK = SOC_FLOOR + 30.0
|
||||
|
||||
days = len(paths)
|
||||
span_first = span_last = None
|
||||
print(f"AC 3 - {days} file(s) from {args.directory}")
|
||||
print(f" shipped: peak_cap_w {CAP_W:.0f} W, meter_max_age_s "
|
||||
f"{MAX_HOLD_S:.0f} s, cycle_floor_s {CYCLE_S:.0f} s, "
|
||||
f"max_w schema ceiling {LIVE_MAX_W:.0f} W")
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="goodwe-ac3-"))
|
||||
kw = dict(cap_w=CAP_W, max_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
cycle_s=CYCLE_S, soc=SOC_OK, soc_floor=SOC_FLOOR, tmp=tmp,
|
||||
gap_s=MAX_HOLD_S)
|
||||
u = replay(paths, guarded=False, **kw)
|
||||
g = replay(paths, guarded=True, **kw)
|
||||
|
||||
def flat(run):
|
||||
return [w for m in run["months"].values() for w in m.values()]
|
||||
|
||||
uq, gq = flat(u), flat(g)
|
||||
u_under = sum(1 for w in uq if w <= CAP_W)
|
||||
g_under = sum(1 for w in gq if w <= CAP_W)
|
||||
u_pct = 100.0 * u_under / max(len(uq), 1)
|
||||
g_pct = 100.0 * g_under / max(len(gq), 1)
|
||||
|
||||
print(f" {u['samples']} samples, {len(uq)} closed quarters "
|
||||
f"({len(uq) / 96.0:.1f} days' worth), {days - u['joins']} contiguous "
|
||||
f"run(s) of days")
|
||||
for m in sorted(u["months"]):
|
||||
mu = u["months"][m]
|
||||
mg = g["months"].get(m, {})
|
||||
print(f" month {m}: {len(mu)} quarters, peak "
|
||||
f"{max(mu.values()):.1f} W uncontrolled -> "
|
||||
f"{max(mg.values()) if mg else 0.0:.1f} W guarded")
|
||||
print()
|
||||
print(f" TARIFF-01 AC 3 (UNCONTROLLED, what the month did): "
|
||||
f"{u_under}/{len(uq)} = {u_pct:.2f} % of quarters at or under "
|
||||
f"{CAP_W:.0f} W")
|
||||
print(f" SAFETY-07 AC 3 (GUARDED, peakguard.decide in the loop): "
|
||||
f"{g_under}/{len(gq)} = {g_pct:.2f} %")
|
||||
print(f" guard cycles {g['cycles']}, engaged on the measurement "
|
||||
f"{g['engaged']}, blind {g['blind']}, saturated {g['saturated']}, "
|
||||
f"modelled discharge {g['wh']:.0f} Wh")
|
||||
# AC 3'S WORDS SUPPORT TWO READINGS AND BOTH ARE REPORTED, because they
|
||||
# disagree on this data and picking the flattering one is the failure mode
|
||||
# this whole card exists to correct.
|
||||
u_peak = max(uq) if uq else 0.0
|
||||
g_peak = max(gq) if gq else 0.0
|
||||
print(f" reading A - PERCENTAGE OF INTERVALS under the cap (the reading "
|
||||
f"test_tariff.py part 9 already uses): uncontrolled {u_pct:.2f} %, "
|
||||
f"guarded {g_pct:.2f} %")
|
||||
print(f" reading B - THE MONTHLY PEAK ITSELF under the cap: "
|
||||
f"{u_peak:.1f} W uncontrolled -> {g_peak:.1f} W guarded, against a "
|
||||
f"{CAP_W:.0f} W soft limit "
|
||||
f"({'under' if g_peak <= CAP_W else 'STILL OVER'})")
|
||||
print(f" verdict at the 95 % criterion: uncontrolled "
|
||||
f"{'MEETS' if u_pct >= 95.0 else 'DOES NOT MEET'}, guarded "
|
||||
f"{'MEETS' if g_pct >= 95.0 else 'DOES NOT MEET'} "
|
||||
f"({len(uq) / 96.0:.1f} days of replay)")
|
||||
|
||||
if u["gaps"]:
|
||||
tot = sum(d for _, _, d in u["gaps"])
|
||||
print(f" gaps over {MAX_HOLD_S:.0f} s: {len(u['gaps'])}, "
|
||||
f"{tot:.0f} s total - NOT interpolated; the quarters they fall in "
|
||||
f"simply carry less coverage")
|
||||
if args.report_gaps:
|
||||
for a, b, d in u["gaps"]:
|
||||
print(f" {a.isoformat()} -> {b.isoformat()} {d:.0f} s")
|
||||
|
||||
if args.expect:
|
||||
want_u, want_t = (int(x) for x in args.expect.split("/"))
|
||||
ok = (u_under, len(uq)) == (want_u, want_t)
|
||||
print(f" reproduces test_peakguard.py part 6's published "
|
||||
f"{want_u}/{want_t}: {'yes' if ok else 'NO'} "
|
||||
f"(got {u_under}/{len(uq)})")
|
||||
return 0 if ok else 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user