TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.
New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.
It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).
THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.
And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).
The market's own arithmetic, reproduced through the shipped code:
Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471 card prints 15,55
ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.5218 card prints 7,522
NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.
Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.
A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.
`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.
22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
+272
-58
@@ -72,9 +72,10 @@ THE THREE THINGS THIS MODULE IS SHAPED BY
|
||||
reads the enum. A plugin id appears nowhere in this file except in
|
||||
`PROVIDERS`, in `RETIRED_PROVIDERS`, and in the class that answers to it.
|
||||
|
||||
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). Both
|
||||
shipped plugins are day-ahead, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
|
||||
and `WIDEN_FACTOR`'s 2.5 currently have no shipped declarer. They are KEPT
|
||||
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). All
|
||||
three shipped plugins are day-ahead, so `_WIDE`,
|
||||
`Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 currently have no
|
||||
shipped declarer. They are KEPT
|
||||
deliberately - see DECISIONS/TARIFF-07-withdrawing-three-plugins.md - and
|
||||
test_price.py section 10 keeps them live by declaring REALTIME from a class
|
||||
defined outside this file, which is AC2's own stimulus.
|
||||
@@ -149,6 +150,10 @@ from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .custom_formula import (
|
||||
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
|
||||
price_eur_kwh, to_resolution)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price")
|
||||
|
||||
|
||||
@@ -846,6 +851,13 @@ PRICE_OPTION_DEFAULTS = {
|
||||
# test_price.py section 1. A second defaults dict in the new module would be
|
||||
# a fourth place option defaults live, which is exactly the open ticket.
|
||||
"static_profile": "",
|
||||
# TARIFF-15. A JSON object carrying ONE contract's two coefficients, the
|
||||
# units they are printed in, its VAT rate and the card figures the add-on
|
||||
# grades them against; "" is unset. Parsed and validated by
|
||||
# app/custom_formula.py, refused at startup by validate.py - the
|
||||
# `static_profile` precedent exactly, and here for the same CFG-03 reason:
|
||||
# this dict is the ONE in-code mirror of config.yaml.
|
||||
"price_formula": "",
|
||||
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
|
||||
#
|
||||
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
|
||||
@@ -983,11 +995,20 @@ def export_price_eur_kwh(opts: dict) -> float:
|
||||
the SAME `B x spot - A` shape as the consumption curve but carries ITS OWN
|
||||
coefficients and its own VAT treatment - injection is VAT-exempt where
|
||||
consumption is at 6% - so it is a second per-contract formula, not a second
|
||||
copy of the first. Nothing in this add-on can evaluate either one yet
|
||||
(`generic_dayahead` cannot express `B x spot + A` at all; the formula
|
||||
provider is a separate card). Until it can, this scalar is a SIMPLIFICATION
|
||||
an operator enters by hand - an average of what they are actually paid -
|
||||
and not a reflection of the market. When the formula provider lands, an
|
||||
copy of the first.
|
||||
|
||||
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
|
||||
difference is the point rather than an omission. `custom_formula` can now
|
||||
express `B x index + A` exactly - it is what it exists for - but it is a
|
||||
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
|
||||
consumption taxes, and an injection leg carries its OWN coefficients
|
||||
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
|
||||
treatment (injection is exempt; consumption is at 6%). Pointing this
|
||||
scalar at that machinery would mean pricing an injection curve with a
|
||||
consumption card, which is the wrong-card defect the card self-check
|
||||
exists to refuse. So until the injection card lands this scalar remains a
|
||||
SIMPLIFICATION an operator enters by hand - an average of what they are
|
||||
actually paid - and not a reflection of the market. When it does land, an
|
||||
injection curve gets a `PriceProvider` of its own and this scalar becomes
|
||||
its FIXED-product default; the seam is already the right shape for that.
|
||||
|
||||
@@ -1046,6 +1067,28 @@ class PriceProvider(ABC):
|
||||
inheriting the silence.
|
||||
"""
|
||||
|
||||
def startup_preview(self, points) -> list:
|
||||
"""Lines to log ONCE, the first time this provider answers (TARIFF-15).
|
||||
|
||||
`points` are the ASSEMBLED, all-in `PricePoint`s - what the site is
|
||||
really priced on - and the default is to say nothing, so no existing
|
||||
plugin changes behaviour.
|
||||
|
||||
⚠️ IT IS A HOOK RATHER THAN A LINE IN `load_prices()` BECAUSE THE
|
||||
FRAMEWORK CANNOT KNOW WHAT IS WORTH PREVIEWING. Only the plugin knows
|
||||
what went IN to its own arithmetic - for `custom_formula` the index in
|
||||
EUR/MWh - and printing that beside the all-in figure is the whole
|
||||
point: a factor of 1000 is obvious in one line of
|
||||
`61.96 -> 0.13522` and invisible in a plan. The framework supplies the
|
||||
one number a plugin may never read, by handing back the assembled
|
||||
result rather than the tax rate.
|
||||
|
||||
⚠️ IT MUST NOT RAISE INTO THE CONTROL PATH and it is not trusted not
|
||||
to: `_emit_startup_preview()` catches everything. A log line is not a
|
||||
reason to stop regulating a battery.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
# ⚠️ THE NAMES A PLUGIN'S OWN CODE MAY NOT MENTION. FRAMEWORK_KEYS is the
|
||||
# obvious half; the other three are the routes review of PR #16 actually used to
|
||||
@@ -1491,6 +1534,60 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
return out
|
||||
|
||||
|
||||
def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
|
||||
"""The day-ahead URL for a window, or the operator's override, verbatim.
|
||||
|
||||
⚠️ SHARED BY `generic_dayahead` AND `custom_formula`, ON PURPOSE
|
||||
(TARIFF-15). They read the SAME feed for the same range; two copies of this
|
||||
rule would be two places for the Brussels-calendar derivation and the
|
||||
override decision to disagree, and the one that is wrong would be wrong
|
||||
silently - §11.4 turns a short horizon into provisional hours, not into an
|
||||
error. It takes `plugin_id` rather than reading one, so the framework's
|
||||
AC2 rule (no plugin id in shared code) still holds here.
|
||||
|
||||
⚠️ AN OVERRIDE IS THE EXACT URL AND NOTHING IS APPENDED TO IT, WHICH COSTS
|
||||
THE OVERRIDING SITE THE RANGE. That is a decision, not an oversight, and
|
||||
the sentence below is why it is not a footnote:
|
||||
|
||||
* `price_feed_url` says "override the plugin's default feed", and an
|
||||
override that is silently rewritten is not one. Merging parameters
|
||||
into an operator's query string means parsing and re-serialising a
|
||||
URL they wrote - which reorders, de-duplicates and re-escapes it,
|
||||
and can hand the server a request nobody typed.
|
||||
* `start`/`end` are ENERGY-CHARTS' spelling, and both providers DECLARE
|
||||
A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has verified that
|
||||
host's day-ahead parameters and nothing here should guess them - but
|
||||
they are not `start`/`end`, so an override to the other DECLARED host
|
||||
is exactly the case where appending our vocabulary adds two parameters
|
||||
that host ignores and yields the short horizon anyway, with nothing
|
||||
saying so.
|
||||
* and blind appending is refused outright: `dated_feed_url()` will not
|
||||
add a query string to a base that has one.
|
||||
|
||||
So the cost is real and it is REPORTED, at fetch time, on the shipped
|
||||
logger, naming the horizon consequence rather than the substitution.
|
||||
§11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
"""
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
plugin_id, override, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
return override
|
||||
first, last = brussels_date_range(since, until)
|
||||
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
|
||||
end=last.isoformat())
|
||||
|
||||
|
||||
@register
|
||||
class GenericDayAhead(PriceProvider):
|
||||
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
|
||||
@@ -1533,57 +1630,22 @@ class GenericDayAhead(PriceProvider):
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
"""Request the range the CALLER asked for, on the Brussels calendar.
|
||||
|
||||
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
|
||||
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
|
||||
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
|
||||
1 firm and 47 carried at 23:00, with the whole trade set inside a
|
||||
single calendar day at every hour of every day. See
|
||||
`brussels_date_range()` for the measurement and for why the calendar
|
||||
is Brussels rather than UTC.
|
||||
⚠️ THE ARGUMENTS ARE USED, and `dayahead_feed_url()` above is where
|
||||
they are turned into a URL. Until TARIFF-09 this method took `since`
|
||||
and `until` and asked for the bare `?bzn=BE`, which answers ONE
|
||||
Brussels day - so a 48 h horizon was 15 firm hours and 33 carried at
|
||||
09:00, and 1 firm and 47 carried at 23:00, with the whole trade set
|
||||
inside a single calendar day at every hour of every day.
|
||||
|
||||
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
|
||||
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
|
||||
oversight, and the sentence below is why it is not a footnote:
|
||||
|
||||
* `price_feed_url` says "override the plugin's default feed", and an
|
||||
override that is silently rewritten is not one. Merging parameters
|
||||
into an operator's query string means parsing and re-serialising a
|
||||
URL they wrote - which reorders, de-duplicates and re-escapes it,
|
||||
and can hand the server a request nobody typed.
|
||||
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
|
||||
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
|
||||
verified that host's day-ahead parameters and nothing here should
|
||||
guess them - but they are not `start`/`end`, so an override to the
|
||||
other DECLARED host is exactly the case where appending our
|
||||
vocabulary adds two parameters that host ignores and yields the
|
||||
short horizon anyway, with nothing saying so.
|
||||
* and blind appending is refused outright: `dated_feed_url()` will
|
||||
not add a query string to a base that has one.
|
||||
|
||||
So the cost is real and it is REPORTED, at fetch time, on the shipped
|
||||
logger, naming the horizon consequence rather than the substitution.
|
||||
§11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
⚠️ THE URL DECISION MOVED OUT OF THIS METHOD IN TARIFF-15 AND DID NOT
|
||||
CHANGE. `custom_formula` reads the same feed with the same range and
|
||||
the same parser, and a second copy of the override rule would be a
|
||||
second place for it to be wrong - which is the reason `covers()`
|
||||
delegates to `at()` rather than restating its window. The reasoning,
|
||||
the warning and the exact URL are all in `dayahead_feed_url()`.
|
||||
"""
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
url = override
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
self.id, url, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
else:
|
||||
first, last = brussels_date_range(since, until)
|
||||
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
|
||||
start=first.isoformat(),
|
||||
end=last.isoformat())
|
||||
url = dayahead_feed_url(self.id, opts, since, until,
|
||||
self.FEED_BASE, self.BZN)
|
||||
check_egress(url, self.capabilities())
|
||||
body = await transport(url, float(opts.get("price_timeout_s")))
|
||||
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
@@ -1660,10 +1722,160 @@ class ManualCsv(PriceProvider):
|
||||
return out
|
||||
|
||||
|
||||
@register
|
||||
class CustomFormula(PriceProvider):
|
||||
"""The operator's OWN tariff card, as two fixed coefficients. TARIFF-15.
|
||||
|
||||
SOURCES: energy, markup
|
||||
The framework ADDS: taxes
|
||||
|
||||
⚠️ IT DECLARES `{ENERGY, MARKUP}` AND NOT `ALL_IN`, AND THAT IS THE WHOLE
|
||||
SAFETY ARGUMENT. A Belgian card's `Tariefformule` line is the ENERGY
|
||||
component only - Eneco tabulates network tariffs and levies separately,
|
||||
ENGIE states that the formula excludes network, distribution, transport,
|
||||
grid levies and excise - so declaring ALL_IN would lie on behalf of every
|
||||
operator who typed exactly what their card says, and would silently drop
|
||||
~12 c EUR/kWh from every hour. The framework adds `price_taxes_eur_kwh`
|
||||
on top, once, and this class cannot see or apply it (trap 1).
|
||||
|
||||
The risk that introduces is one no guard in this file can see, because it
|
||||
is not in code: THE OPERATOR folding their network charges into `offset`
|
||||
while the tax option carries them too. `app/custom_formula.py`'s card
|
||||
self-check is what refuses that - the card's printed estimate is the
|
||||
energy component alone, so an inflated offset moves 15.55 to ~27 and the
|
||||
add-on will not start. Its limit is stated in the same breath there: it
|
||||
grades the coefficients against the card, NOT the card against reality.
|
||||
|
||||
⚠️ NO EXPRESSION EVALUATION OF ANY KIND, AND THE COEFFICIENTS ARE NUMBERS
|
||||
BECAUSE THE PARSER REFUSES ANYTHING ELSE. This string is settable over
|
||||
HTTP through the add-on's own ingress config page, so `eval` on it would
|
||||
be RCE in a root container holding SUPERVISOR_TOKEN - and, the deciding
|
||||
argument, an arbitrary expression could not be graded against the card at
|
||||
all. See app/custom_formula.py.
|
||||
|
||||
⚠️ THE INDEX PATH IS `generic_dayahead`'s, NOT A SECOND ONE. Same feed,
|
||||
same `dayahead_feed_url()` request on the Brussels calendar, same columnar
|
||||
parser, same declared egress hosts. A second parser would be a second
|
||||
failure mode for a document that already has one that works.
|
||||
"""
|
||||
|
||||
id = "custom_formula"
|
||||
|
||||
FEED_BASE = "https://api.energy-charts.info/price"
|
||||
BZN = "BE"
|
||||
|
||||
# Written by fetch(), read by startup_preview(), and by nothing else.
|
||||
spec = None
|
||||
index_by_instant: dict = {}
|
||||
|
||||
@classmethod
|
||||
def capabilities(cls):
|
||||
return ProviderCapabilities(
|
||||
product=PriceProduct.DAY_AHEAD,
|
||||
sources=frozenset({Component.ENERGY, Component.MARKUP}),
|
||||
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
"""Fetch the index, apply the card's arithmetic, return RawPoints.
|
||||
|
||||
⚠️ `since`/`until` ARE USED, through the shared URL builder - see
|
||||
`dayahead_feed_url()` and TARIFF-09 for what ignoring them costs.
|
||||
"""
|
||||
try:
|
||||
spec = parse_price_formula(opts.get("price_formula"))
|
||||
except FormulaInvalid as e:
|
||||
raise SourceUnavailable(f"price_formula: {e}")
|
||||
if spec is None:
|
||||
raise SourceUnavailable(
|
||||
"price_provider is 'custom_formula' but price_formula is "
|
||||
"empty, so there are no coefficients to price anything with. "
|
||||
"validate.py refuses this configuration at startup; reaching "
|
||||
"it here means the options blob changed under a running "
|
||||
"add-on")
|
||||
url = dayahead_feed_url(self.id, opts, since, until,
|
||||
self.FEED_BASE, self.BZN)
|
||||
check_egress(url, self.capabilities())
|
||||
body = await transport(url, float(opts.get("price_timeout_s")))
|
||||
# ⚠️ `scale=1.0`, SO THESE RawPoints CARRY EUR/**MWh** IN A FIELD
|
||||
# SPELLED `eur_per_kwh`. That is a trap and it is contained on purpose:
|
||||
# the list is destructured on the very next line and never leaves this
|
||||
# method, so nothing downstream can read the wrong unit off it. The
|
||||
# alternative - scaling to EUR/kWh and multiplying by 1000 again to
|
||||
# feed the formula - is two conversions where the contract needs none,
|
||||
# and every extra conversion here is another place to lose a factor of
|
||||
# 1000. If this list ever DID escape, `_validate_raw()` would refuse it
|
||||
# at `MAX_PLAUSIBLE_EUR_KWH` rather than price a day on it.
|
||||
index = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=1.0,
|
||||
provisional_default=False)
|
||||
pairs = to_resolution([(p.start, p.eur_per_kwh) for p in index],
|
||||
spec.resolution)
|
||||
self.spec = spec
|
||||
self.index_by_instant = {t.astimezone(timezone.utc): v
|
||||
for t, v in pairs}
|
||||
# ⚠️ `provisional=False` FOR THE SAME REASON `generic_dayahead` SAYS
|
||||
# SO: the day-ahead auction is settled once published, and the columnar
|
||||
# document carries no per-point flag to read one from. FCST-04's
|
||||
# carry-forward is what marks the hours nobody published yet.
|
||||
return [RawPoint(start=t, eur_per_kwh=price_eur_kwh(spec, v),
|
||||
provisional=False)
|
||||
for t, v in pairs]
|
||||
|
||||
def startup_preview(self, points) -> list:
|
||||
"""`index EUR/MWh -> all-in EUR/kWh` for the first 24 h, once.
|
||||
|
||||
⚠️ THE INDEX COLUMN IS WHY THIS HOOK EXISTS. The all-in price alone
|
||||
says nothing about which of the two numbers is wrong; printed beside
|
||||
the index it came from, a factor of 1000 is one glance. See
|
||||
`preview_lines()` for the rows and the min/median/max/spread line.
|
||||
"""
|
||||
if self.spec is None or not points:
|
||||
return []
|
||||
cut = points[0].start + timedelta(hours=PREVIEW_HOURS)
|
||||
rows = []
|
||||
for point in points:
|
||||
if point.start >= cut:
|
||||
break
|
||||
index = self.index_by_instant.get(
|
||||
point.start.astimezone(timezone.utc))
|
||||
if index is not None:
|
||||
rows.append((point.start, index, point.eur_per_kwh))
|
||||
return preview_lines(self.spec, rows)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The one entry point
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Provider ids whose startup preview has already been emitted in this process.
|
||||
# ⚠️ NOT A PLUGIN NAME IN THE FRAMEWORK: it is filled at runtime from whatever
|
||||
# id was resolved, and starts empty. AC2 is intact.
|
||||
_PREVIEWED: set = set()
|
||||
|
||||
|
||||
def _emit_startup_preview(plugin, pid: str, points) -> None:
|
||||
"""Log a plugin's preview once per process, and never fail because of it.
|
||||
|
||||
⚠️ THE ID IS RECORDED BEFORE THE HOOK RUNS, DELIBERATELY. A preview that
|
||||
raises must not be retried every 30 minutes for the life of the add-on -
|
||||
that would turn a cosmetic defect into a log flood on the one path §11.4
|
||||
already makes hard to read.
|
||||
"""
|
||||
if pid in _PREVIEWED:
|
||||
return
|
||||
_PREVIEWED.add(pid)
|
||||
try:
|
||||
lines = list(plugin.startup_preview(points))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
|
||||
"unaffected - this is a log line, not a decision",
|
||||
pid, type(e).__name__, e)
|
||||
return
|
||||
for line in lines:
|
||||
_LOG.info("%s", line)
|
||||
|
||||
|
||||
|
||||
async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
transport=None) -> PriceRead:
|
||||
"""Resolve the configured provider, fetch, assemble all-in, return a read.
|
||||
@@ -1709,8 +1921,9 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
|
||||
if transport is None:
|
||||
transport = https_get
|
||||
plugin = cls()
|
||||
try:
|
||||
raw = await cls().fetch(PluginOptions(opts), since, until, transport)
|
||||
raw = await plugin.fetch(PluginOptions(opts), since, until, transport)
|
||||
_validate_raw(raw, caps)
|
||||
points = assemble(caps, raw, markup, taxes)
|
||||
except FrameworkOption:
|
||||
@@ -1720,5 +1933,6 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
except Exception as e: # noqa: BLE001 - see the docstring
|
||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
||||
|
||||
_emit_startup_preview(plugin, pid, points)
|
||||
return PriceRead.series(points, caps.product, pid,
|
||||
datetime.now(timezone.utc))
|
||||
|
||||
Reference in New Issue
Block a user