MAINT-20 part 1: price_chain.py comments trimmed
checks / unit (pull_request) Failing after 38m0s
infra-pin / fresh (pull_request) Successful in 2s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-01 01:19:29 +01:00
co-authored by Claude
parent c432d4e419
commit 72ccfd788c
+26 -61
View File
@@ -1,27 +1,15 @@
"""PRICE-01 - where the prices come from when the first source does not answer. """PRICE-01 - where the prices come from when the first source does not answer.
A provider that names `FALLBACKS` (app/price.py) is read through this module A provider that names `FALLBACKS` (app/price.py) is read through here: first the
instead of straight from `load_prices()`. The order is fixed: provider (the owner's price server), then each id in `FALLBACKS`, then the
prices the history store kept from the last fetch that worked. A step counts
only if its answer holds a price for NOW (a server answering 200 with
yesterday's prices is as useless as one that is down). Other providers never
come here (`uses_chain()` is False).
1. the provider itself (the owner's price server) `gate_for()` is the maintenance charge rule for such a site: the current
2. each id in its `FALLBACKS` (energy-charts.info) 15-minute price is at or below today's average, as the Home Assistant pair gave.
3. the prices the history store kept from the last fetch that worked DECISIONS/PRICE-01-own-price-server.md says how equal, and where it is not."""
A step counts only if its answer holds a price for NOW. A server that answers
200 with yesterday's prices is as useless to a battery as one that is down, so
it is skipped, with the same log line.
Every other provider never comes here (`uses_chain()` is False for it), so
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
always took.
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
provider: the current 15-minute price is at or below today's average. It is the
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
from the provider's own prices, so moving a site from the pair to the provider
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
says how equal, and where it is not.
"""
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
@@ -74,13 +62,8 @@ class Resolved:
class PriceWatch: class PriceWatch:
"""Which step the prices come from, and one log line per change of it. """Which step the prices come from, and one log line per change of it (nothing
repeats while a source stays down). Reasons go on a DEBUG line, token removed."""
The line is written when the step CHANGES and not before. Nothing repeats
while a source stays down, and nothing is written for a refresh that
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
beside it, with the token taken out.
"""
def __init__(self): def __init__(self):
self.step = None self.step = None
@@ -123,20 +106,14 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
history, stamp: datetime, watch: PriceWatch, history, stamp: datetime, watch: PriceWatch,
load) -> Resolved: load) -> Resolved:
"""Walk the chain and return the first step that has a price for `stamp`. """Walk the chain and return the first step that has a price for `stamp`.
`load` is the caller's `load_prices`, so static_tariff.py stays the one module
`load` is the caller's `load_prices`, passed in so that `static_tariff.py` that fetches. Never raises for a source condition: `no_data` names every step."""
stays the one module that fetches prices. Never raises
for a source condition: a read that gets nowhere is a `no_data` whose
reason names every step, so the §11.4 row applies exactly as for a single
provider that failed.
"""
pid = provider_id(opts) pid = provider_id(opts)
cls = PROVIDERS[pid] cls = PROVIDERS[pid]
reasons = [] reasons = []
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)): for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
# A fallback is asked for ITS OWN feed. The operator's feed override is # A fallback is asked for ITS OWN feed; the operator's override is for
# for the first provider and would send energy-charts' request to a # the first provider.
# URL it was never meant for.
o = opts if step == 0 else dict(opts, price_provider=sid, o = opts if step == 0 else dict(opts, price_provider=sid,
price_feed_url="") price_feed_url="")
read = await load(o, since, until, transport) read = await load(o, since, until, transport)
@@ -158,8 +135,8 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
series=lambda r: "price_history: it has no price for now", series=lambda r: "price_history: it has no price for now",
no_data=lambda r: r.reason)) no_data=lambda r: r.reason))
watch.note(STEP_NONE, reasons, opts) watch.note(STEP_NONE, reasons, opts)
# The reason is short on purpose: it is printed on the status page and in # Short on purpose: it is on the status page and in a WARNING every
# a WARNING every refresh. What each step said is on the DEBUG line. # refresh.
return Resolved(PriceRead.no_data( return Resolved(PriceRead.no_data(
"no price source has a price for now"), STEP_NONE, "no price source has a price for now"), STEP_NONE,
tuple(scrub(r, opts) for r in reasons)) tuple(scrub(r, opts) for r in reasons))
@@ -171,13 +148,9 @@ async def resolve(opts: dict, since: datetime, until: datetime, transport,
@dataclass(frozen=True) @dataclass(frozen=True)
class Gate: class Gate:
"""What the 30 s tick takes from the standing plan. """What the 30 s tick takes from the standing plan. `covered` False means `cheap`
is False and the charge waits (no price is not a cheap price). `since` is when
`covered` is whether a published price covers now. When it is False, the cover ended, or None."""
`cheap` is False and the charge waits: no price is not a cheap price.
`since` is when the cover ended, for the status line, or None when nothing
was ever published.
"""
cheap: bool cheap: bool
covered: bool covered: bool
@@ -189,24 +162,16 @@ NO_PLAN = Gate(False, False, None, "No prices yet")
def _round5(value: float) -> float: def _round5(value: float) -> float:
# The Home Assistant template rounded the average to 5 decimals, and a # The Home Assistant template rounded both sides to 5 decimals; so do we,
# price to 5 as well. Both sides are compared as it did, so a quarter that # for the same answer.
# sits within 0.00001 EUR/kWh of the average gets the same answer.
return round(float(value), 5) return round(float(value), 5)
def gate_for(published, now: datetime) -> Gate: def gate_for(published, now: datetime) -> Gate:
"""The charge gate for `now`, from the series the chain published. """The charge gate for `now`, from the series the chain published: the current
15-minute price is at or below the average of today's (Brussels day) prices,
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or as `now_p <= avg_p` was on the HA pair (owner, PRICE-01). Not
below the average of today's prices, today being the Brussels day. This is `TariffPlan.cheap_now()`. With no price for now the charge waits."""
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
hours of the whole plan, and it is not meant to be.
`published` is a series or None. With no price for now there is no gate
answer: the charge waits.
"""
if published is None: if published is None:
return NO_PLAN return NO_PLAN
if not published.covers(now): if not published.covers(now):