PRICE-01: price_service provider, fallback chain, and the gate read from it
checks / unit (pull_request) Successful in 39m31s
infra-pin / fresh (pull_request) Successful in 1s

A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.

The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.

No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.

The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-14 16:22:24 +02:00
co-authored by Claude
parent 0ce7d35f80
commit 9ac3a9d2bc
21 changed files with 2169 additions and 32 deletions
+15
View File
@@ -582,6 +582,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
runs += cyc
lines += waits
if getattr(c, "price_gap", False):
lines.append(line_no_prices(c.price_gap_since, now))
runs += peak_run(c, day0, day1, now, shadow, limit_w)
if c.p1_enabled:
# The guard's ceiling: the cap, or this month's peak once above it.
@@ -1268,6 +1270,13 @@ def line_cycle_waits(name) -> str:
return _t("cycle_waits", name=name)
def line_no_prices(since, now) -> str:
"""PRICE-01: no price covers now. `since` is an instant or None."""
if since is None:
return _t("no_prices_yet")
return _t("no_prices_since", at=messages.since_text(brussels(since), brussels(now)))
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "step_in", kw=_kw(limit_w))
@@ -1387,6 +1396,12 @@ def _samples() -> list:
("plan", run_house_why()), ("plan", run_house_why(True)),
("plan", run_house_amount()),
("plan", line_cycle_waits("Battery B")),
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
tzinfo=timezone.utc),
datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
{"eur_kwh": 0.36, "label": "19:00"})),
("plan", line_prices(None, None, None)),
+82 -1
View File
@@ -90,6 +90,7 @@ from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import PriceWatch, gate_for, uses_chain
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
@@ -1202,6 +1203,16 @@ class Controller:
self.peak_fc = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01. Which step of the price chain the prices come from (logged
# once per change), the last series that was published (so a gap can say
# since when), and the gap itself: True while a refresh has finished and
# no published price covers now. The notification is raised once per
# gap and taken down when it ends.
self.price_watch = PriceWatch()
self._last_published = None
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# 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:
@@ -3435,6 +3446,12 @@ class Controller:
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self._price_gated = True
self.tariff_words = "Prices from Home Assistant entities"
elif uses_chain(o):
# PRICE-01. A provider with fallbacks gives the maintenance gate
# its own prices: the current 15-minute price against today's
# average, as the Home Assistant pair did. Still no I/O here - the
# series is the one the background refresh stored.
self._provider_gate(datetime.now(timezone.utc))
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
@@ -3998,6 +4015,35 @@ class Controller:
inv.refusal_note())]
if n]
def _provider_gate(self, now: datetime) -> None:
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
The gate exists whenever the provider does: no price for now is a
closed gate, so the charge waits and does not run unpriced. The gap
flag stays False until the first refresh has finished, so a start-up
is not a gap.
"""
plan = self.fresh_tariff_plan(now)
published = None if plan is None else plan.published
if published is not None:
self._last_published = published
gate = gate_for(published, now)
self.cheap, self._price_gated = gate.cheap, True
self.tariff_words = gate.words
self.price_gap = (not gate.covered) and self.tariff_at is not None
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
elif last.covers(now):
# The last series still covers now, but no refresh has made it
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
# gap began when the plan was retired.
self.price_gap_since = (
None if self.tariff_at is None
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
else:
self.price_gap_since = last.last_end_before(now)
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
@@ -5536,6 +5582,11 @@ class Controller:
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- PRICE-01: no price for now, said once and taken down once --------
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this method
@@ -6152,6 +6203,33 @@ class Controller:
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one does.
`price_gap` is worked out on the tick from the stored plan. A gap
raises the notification once: the flag is set BEFORE the await, for
`check_circulating()`'s reason (a Home Assistant call can raise or
hang, and a retry every cycle would be a storm). When the gap ends the
same notification is taken down. Control is not touched either way: the
maintenance charge waits because its gate is shut (`_provider_gate`),
and nothing here decides that.
"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(self._gap_text()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
def _gap_text(self) -> str:
"""`00:00` for a gap that began today, `13 Oct at 21:00` before that."""
if self.price_gap_since is None:
return ""
return messages.since_text(brussels(self.price_gap_since),
brussels(datetime.now(timezone.utc)))
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -6286,7 +6364,7 @@ class Controller:
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
history=self.price_history, watch=self.price_watch)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
@@ -7363,6 +7441,9 @@ class Controller:
("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),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(self._gap_text())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# It is its own row rather than a clause of "Tariff" because it
+55
View File
@@ -129,6 +129,8 @@ S_NOT_DELIVERING = "An inverter is not delivering"
T_METER_FROZEN = "Meter reading may be stuck"
T_MAINT_FAILED = "Battery cycle did not finish"
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
T_NO_PRICES = "No prices"
T_NO_PRICES_NL = "Geen prijzen"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -138,6 +140,7 @@ ID_CIRCULATING = "goodwe_circulating_power"
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
ID_NO_PRICES = "goodwe_no_prices"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
# The setting is named by its label, "Inverter rating (W)".
@@ -314,6 +317,51 @@ def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
def since_text(since, now) -> str:
"""PRICE-01: `00:00` for a moment today, `13 Oct at 21:00` for an earlier
day. Both arguments are already in the site's zone. Empty for no moment."""
if since is None:
return ""
return f"{since:%H:%M}" if since.date() == now.date() else when(since)
def no_prices(since_text: str = "") -> Notice:
"""PRICE-01: no source has a price for now. Said once, gone with the gap."""
if since_text:
en = (f"No prices since {since_text}, so the monthly battery cycle "
f"waits until they return.")
nl = (f"Geen prijzen sinds {since_text}, dus de maandelijkse "
f"batterijcyclus wacht tot ze terug zijn.")
else:
en = ("No prices have come in yet, so the monthly battery cycle waits "
"until they do.")
nl = ("Er zijn nog geen prijzen binnen, dus de maandelijkse "
"batterijcyclus wacht tot ze er zijn.")
return Notice(ID_NO_PRICES, WARNING, T_NO_PRICES, en,
body_nl=f"{T_NO_PRICES_NL}. {nl}")
@dataclass(frozen=True)
class Dismiss:
"""The payload that takes one notification down again."""
id: str
def data(self) -> dict:
return {"notification_id": self.id}
def prices_back() -> Dismiss:
"""PRICE-01: the 'No prices' notification, taken down when a price is back."""
return Dismiss(ID_NO_PRICES)
def row_no_prices(since_text: str = "") -> str:
"""PRICE-01: the status row while no price covers now."""
if since_text:
return f"No prices since {since_text}, maintenance waits."
return "No prices yet, maintenance waits."
def row_maint_waits(until_text: str) -> str:
"""MAINT-13: the status row while a failed cycle waits."""
return f"Waits until {until_text} after a cycle that did not finish."
@@ -1186,6 +1234,9 @@ def status_samples() -> list:
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
("row_no_prices", AMBER, row_no_prices("00:00")),
("row_no_prices", AMBER, row_no_prices("13 Oct at 21:00")),
("row_no_prices", AMBER, row_no_prices()),
]
@@ -1218,6 +1269,9 @@ def samples() -> list:
meter_frozen(90),
maint_failed("charge", 7),
maint_failed("drain", 7, "inverter-b", "inverter_b"),
no_prices("00:00"),
no_prices("13 Oct at 21:00"),
no_prices(),
]
@@ -1250,6 +1304,7 @@ LABELS = {
"price_formula": "Your own tariff formula",
"price_export_formula": "Your own injection formula",
"price_csv_path": "Price CSV file",
"price_service_token": "Price service token",
"price_now_entity": "Current price entity",
"price_avg_entity": "Average price entity",
"arbitrage_enabled": "Trade on the price spread",
+171 -7
View File
@@ -750,6 +750,18 @@ class _Series(PriceRead):
except Absence:
return False
def last_end_before(self, instant: datetime):
"""When the last price that has run out by `instant` stopped covering,
or None if no price has (PRICE-01: "no prices since 00:00").
Each point's end is the one `_cover_table()` worked out, so a hole in
the series is reported from where the hole starts and not from the end
of the day.
"""
u = instant.astimezone(timezone.utc)
ends = [end for _start, end, _p in self._cover if end <= u]
return max(ends) if ends else None
def window(self, since: datetime, until: datetime) -> tuple:
"""Points whose START falls in [since, until). May legitimately be empty.
@@ -906,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
# PRICE-01. The bearer token of the owner's price server. A secret: it is
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
# the status page or a notification with it in.
"price_service_token": "",
"price_horizon_h": 48,
"price_timeout_s": 10,
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
@@ -992,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
})
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
# from any text the price code builds out of an error.
SECRET_OPTIONS = frozenset({"price_service_token"})
def scrub(text, opts) -> str:
"""`text` with every secret option's value replaced by `***`.
The price code builds reasons out of library errors, and a library may put
a request header into its error text. Taking the value out here is the
last line of defence; the plugin also never puts it in a message itself.
"""
out = str(text)
for key in SECRET_OPTIONS:
secret = str((opts or {}).get(key) or "").strip()
if len(secret) >= 4:
out = out.replace(secret, "***")
return out
class PluginOptions:
"""The options dict, minus the keys only the framework may read.
@@ -1216,6 +1252,13 @@ class PriceProvider(ABC):
id = "?"
# PRICE-01. The ids to try, in order, when this source cannot answer, and
# whether the last answer is kept for a day without any source. Empty and
# False for every plugin that existed before, which is what keeps them
# exactly as they were: `price_chain.py` is not used for them at all.
FALLBACKS: tuple = ()
KEEPS_LAST_GOOD: bool = False
@classmethod
@abstractmethod
def capabilities(cls) -> ProviderCapabilities:
@@ -1533,19 +1576,54 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
# Transport - one place, HTTPS only, host checked against the declaration
# --------------------------------------------------------------------------- #
async def https_get(url: str, timeout_s: float) -> bytes:
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
"""The default transport. §16: outbound is allow-listed and TLS'd.
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
to be importable at all. AC3 says it works fully offline; that includes a
machine where the HTTP stack is missing.
PRICE-01. `headers` is None for every plugin but `price_service`, and with
None this is the code it has always been, byte for byte. With headers it
answers in the SAFE words of `status_words()` and never lets a library
error through: an error from a request that carried a token is the one
place a token could be printed by somebody else's code.
"""
import aiohttp
t = aiohttp.ClientTimeout(total=float(timeout_s))
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url) as r:
r.raise_for_status()
return await r.read()
if headers is None:
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url) as r:
r.raise_for_status()
return await r.read()
try:
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url, headers=headers) as r:
if r.status != 200:
raise SourceUnavailable(status_words(r.status))
return await r.read()
except SourceUnavailable:
raise
except Exception as e: # noqa: BLE001
raise SourceUnavailable(
f"the price service did not answer ({type(e).__name__})")
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
# reads it to tell "the owner must act" from "the server is down", so it is a
# constant both sides import.
AUTH_REFUSED = "the price service refused the token"
def status_words(status: int) -> str:
"""What an HTTP status from the price service is called. No header text."""
if status in (401, 403):
return f"{AUTH_REFUSED} ({status})"
if status == 429:
return "the price service says too many requests (429)"
if status >= 500:
return f"the price service has an error ({status})"
return f"the price service answered {status}"
def check_egress(url: str, caps: ProviderCapabilities) -> str:
@@ -1870,6 +1948,91 @@ class GenericDayAhead(PriceProvider):
provisional_default=False)
@register
class PriceService(PriceProvider):
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
SOURCES: energy
The framework ADDS: markup, taxes
The server answers the energy-charts layout (`unix_seconds`, `price`,
`unit`) plus `covers`, the complete Brussels days it holds. The price is
the wholesale energy price and nothing else, so markup and taxes are added
by the framework exactly as for `generic_dayahead`.
The host is fixed here, HTTPS only, and a bearer token (the option
`price_service_token`) goes in the `Authorization` header. The token is not
put into any message this class builds.
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
then the stored prices; this class only fetches.
"""
id = "price_service"
HOST = "prices.kammenstraatha.duckdns.org"
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
FALLBACKS = ("generic_dayahead",)
KEEPS_LAST_GOOD = True
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}),
egress_hosts=(cls.HOST,))
async def fetch(self, opts, since, until, transport):
token = str(opts.get("price_service_token") or "").strip()
if not token:
raise SourceUnavailable("no token is set for the price service")
if any(c.isspace() or not c.isprintable() for c in token):
raise SourceUnavailable(
"the price service token has a space or a line break in it")
check_egress(self.URL, self.capabilities())
try:
body = await transport(
self.URL, float(opts.get("price_timeout_s")),
headers={"Authorization": "Bearer " + token})
except SourceUnavailable:
raise
except Exception as e: # noqa: BLE001
raise SourceUnavailable(
f"the price service did not answer ({type(e).__name__})")
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=0.001,
provisional_default=False)
covers = _covered_days(body)
if covers is None:
return points
whole = [p for p in points
if brussels(p.start).date().isoformat() in covers]
if not whole:
raise SourceUnavailable(
"the price service sent no complete day")
return whole
def _covered_days(body):
"""The `covers` list of a price service answer as a set, or None if absent.
A day the server does not list as complete is not used: an hour missing
inside a day is a hole, and a hole is not a free hour.
"""
try:
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
else str(body)
covers = json.loads(text).get("covers")
except (ValueError, UnicodeDecodeError, AttributeError):
return None
if covers is None:
return None
if not isinstance(covers, list) or \
not all(isinstance(d, str) for d in covers):
raise SourceUnavailable("the price service sent a bad list of days")
return set(covers)
@register
class ManualCsv(PriceProvider):
"""A file on disk. AC3: the documented fallback, fully offline.
@@ -2169,9 +2332,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
except FrameworkOption:
raise # a plugin bug, not a site condition
except SourceUnavailable as e:
return PriceRead.no_data(f"{pid}: {e}")
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
except Exception as e: # noqa: BLE001 - see the docstring
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
return PriceRead.no_data(
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
_emit_startup_preview(plugin, pid, points)
return PriceRead.series(points, caps.product, pid,
+217
View File
@@ -0,0 +1,217 @@
"""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
instead of straight from `load_prices()`. The order is fixed:
1. the provider itself (the owner's price server)
2. each id in its `FALLBACKS` (energy-charts.info)
3. 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 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
from dataclasses import dataclass
from datetime import datetime
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
provider_id, scrub)
_LOG = logging.getLogger("goodwe.price.chain")
# The four places a plan can come from, best first. A higher number is further
# down the chain.
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
# What a step is called in a status line.
SOURCE_WORDS = {
"price_service": "the price service",
"generic_dayahead": "energy-charts",
"price_history": "saved prices",
}
def uses_chain(opts: dict) -> bool:
"""Does the selected provider ask for the fallback chain?"""
cls = PROVIDERS.get(provider_id(opts))
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
@dataclass(frozen=True)
class Resolved:
"""What the chain found. `published` is the series BEFORE the yesterday
fill and the window, which is what the day's average has to be read from:
the plan itself starts at the current hour."""
read: PriceRead
step: int
reasons: tuple = ()
@property
def published(self):
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
class PriceWatch:
"""Which step the prices come from, and one log line per change of it.
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):
self.step = None
self.auth_refused = False
def note(self, step: int, reasons=(), opts=None) -> None:
old, self.step = self.step, step
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
if old == step:
return
if reasons:
_LOG.debug("Price sources said: %s.",
scrub("; ".join(reasons), opts))
if step == STEP_OWN:
if old is None:
_LOG.info("Prices come from the price service.")
else:
_LOG.info("The price service answers again.")
elif step == STEP_PUBLIC:
if old is not None and old > step:
_LOG.info("Prices come from energy-charts again.")
elif self.auth_refused:
_LOG.warning("The price service refused the token, so prices "
"come from energy-charts.")
else:
_LOG.warning("The price service does not answer, so prices "
"come from energy-charts.")
elif step == STEP_STORED:
_LOG.warning("No price source answers, so the add-on uses the "
"prices it saved.")
else:
_LOG.warning("No price source answers, and no saved price covers "
"now.")
async def resolve(opts: dict, since: datetime, until: datetime, transport,
history, stamp: datetime, watch: PriceWatch,
load) -> Resolved:
"""Walk the chain and return the first step that has a price for `stamp`.
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
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)
cls = PROVIDERS[pid]
reasons = []
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
# A fallback is asked for ITS OWN feed. The operator's feed override is
# for the first provider and would send energy-charts' request to a
# URL it was never meant for.
o = opts if step == 0 else dict(opts, price_provider=sid,
price_feed_url="")
read = await load(o, since, until, transport)
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
# One fallback is shipped. Any further one would also be "public".
where = STEP_OWN if step == 0 else STEP_PUBLIC
watch.note(where, reasons, opts)
return Resolved(read, where, tuple(reasons))
reasons.append(read.match(
series=lambda r: f"{sid}: it has no price for now",
no_data=lambda r: r.reason))
if cls.KEEPS_LAST_GOOD:
kept = history.stored(stamp)
if kept.match(series=lambda r: r.covers(stamp),
no_data=lambda _r: False):
watch.note(STEP_STORED, reasons, opts)
return Resolved(kept, STEP_STORED, tuple(reasons))
reasons.append(kept.match(
series=lambda r: "price_history: it has no price for now",
no_data=lambda r: r.reason))
watch.note(STEP_NONE, reasons, opts)
# The reason is short on purpose: it is printed on the status page and in
# a WARNING every refresh. What each step said is on the DEBUG line.
return Resolved(PriceRead.no_data(
"no price source has a price for now"), STEP_NONE,
tuple(scrub(r, opts) for r in reasons))
# --------------------------------------------------------------------------- #
# The maintenance charge gate, read from the provider's prices
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Gate:
"""What the 30 s tick takes from the standing plan.
`covered` is whether a published price covers now. When it is False,
`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
covered: bool
since: datetime | None
words: str
NO_PLAN = Gate(False, False, None, "No prices yet")
def _round5(value: float) -> float:
# The Home Assistant template rounded the average to 5 decimals, and a
# price to 5 as well. Both sides are compared as it did, so a quarter that
# sits within 0.00001 EUR/kWh of the average gets the same answer.
return round(float(value), 5)
def gate_for(published, now: datetime) -> Gate:
"""The charge gate for `now`, from the series the chain published.
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
below the average of today's prices, today being the Brussels day. This is
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:
return NO_PLAN
if not published.covers(now):
return Gate(False, False, published.last_end_before(now),
"No price for now")
today = brussels(now).date()
todays = [p.eur_per_kwh for p in published.points
if not p.provisional and brussels(p.start).date() == today]
here = published.at(now)
if here.provisional or not todays:
return Gate(False, False, published.last_end_before(now),
"No price for now")
avg = sum(todays) / len(todays)
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
source = SOURCE_WORDS.get(published.provider, published.provider)
return Gate(cheap, True, None,
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
f"{'cheap' if cheap else 'not cheap'} against today's average "
f"of {avg:.4f}")
+43 -3
View File
@@ -581,7 +581,7 @@ class PriceHistory:
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
stamp: datetime, remember: bool = True) -> PriceRead:
"""Remember what this read published, then §8.3-fill the missing hours.
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
@@ -599,7 +599,11 @@ class PriceHistory:
source can price is left out (§11.4 for that hour), and `no_data` comes
back when that is every hour.
"""
self._remember(read, stamp)
if remember:
# PRICE-01: False only for a read that CAME FROM this store
# (`stored()`), which is already filed; filing it again would
# rewrite the same file for nothing, and nothing else may pass it.
self._remember(read, stamp)
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
since=since, until=until, stamp=stamp)
@@ -798,6 +802,37 @@ class PriceHistory:
str(rec.get("provider") or "price_history"),
stamp)
def stored(self, stamp: datetime) -> PriceRead:
"""PRICE-01: every price this store holds, as one series, or no_data.
This is the LAST step of the price server's fallback (price_chain.py):
the answer of the last fetch that worked, kept on disk, so a site can
plan for as long as the days it holds last. It is a read of what was
PUBLISHED and filed, in the order they happened, and it is never the
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
read is filled, and it is not filed again).
The prices are the all-in ones stored when they were fetched. They are
not worked out again, so a change of markup or taxes shows from the
next fetch that works, not in these.
"""
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
f"instant")
points = []
for day in sorted(self._days):
rec = self._days[day]
got, _bad = _parse_points(rec.get("points") if isinstance(
rec, dict) else None)
points.extend(got)
if not points:
why = self.last_error or "it holds no prices"
return PriceRead.no_data(
f"the price history at {self.path} gives nothing: {why}")
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
"price_history", stamp)
# -- observability -------------------------------------------------------
@property
def words(self) -> str:
@@ -873,9 +908,14 @@ class NoHistory:
last_error = "no history is kept by this caller"
words = "not kept"
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
def carry(self, read: PriceRead, *, since, until, stamp,
remember: bool = True) -> PriceRead:
return read
def stored(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data("this caller keeps no price history, so "
"there are no stored prices")
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history, so there are no prices from "
+35 -4
View File
@@ -204,12 +204,13 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from . import messages, price_chain
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
provider_configured, required_spread, uncertainty_of, widen_factor,
)
from .price_chain import uses_chain
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -793,6 +794,13 @@ class TariffPlan:
self.read = read
self.profile = profile
self.origin = origin
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
# cut it to the hours ahead: the maintenance gate needs the whole of
# today to know its average. None for every plan that did not come
# through the chain, which is every plan of every other provider.
self.published = None
self.step = None
self.reasons = ()
# -- derived facts -------------------------------------------------------
@property
@@ -1274,7 +1282,8 @@ def static_status(opts: dict, now: datetime):
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None, *, history) -> TariffPlan:
transport=None, now=None, *, history,
watch=None) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
@@ -1330,13 +1339,30 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
if not provider_configured(opts):
return static_plan(opts, since, until, provisional=False, now=stamp)
if uses_chain(opts):
# PRICE-01. The provider names fallbacks, so its prices come from the
# first step of the chain that has one for now. Nothing else does.
found = await price_chain.resolve(
opts, since, until, transport, history, stamp,
watch if watch is not None else price_chain.PriceWatch(),
load=load_prices)
plan = found.read.match(
series=lambda r: _provider_plan(
opts, r, since, until, stamp, history,
remember=found.step != price_chain.STEP_STORED),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
plan.published, plan.step = found.published, found.step
plan.reasons = found.reasons
return plan
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
def _provider_plan(opts, read, since, until, stamp, history, *,
remember=True) -> TariffPlan:
"""§8.3: record what the feed published, carry yesterday into the gaps.
The fill can itself return `no_data` - a feed whose points all fall outside
@@ -1344,7 +1370,12 @@ def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
§11.4 row arrived at one step later, so it degrades exactly as a failed
fetch does rather than being special-cased here.
"""
filled = history.carry(read, since=since, until=until, stamp=stamp)
if remember:
filled = history.carry(read, since=since, until=until, stamp=stamp)
else:
# PRICE-01: a read that came out of the store is not filed again.
filled = history.carry(read, since=since, until=until, stamp=stamp,
remember=False)
return filled.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
+3 -1
View File
@@ -35,7 +35,7 @@
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
},
@@ -410,6 +410,8 @@
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
},
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"no_prices_yet": "No prices yet, maintenance waits.",
"no_prices_since": "No prices since {at}, maintenance waits.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
+3 -1
View File
@@ -35,7 +35,7 @@
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
},
@@ -345,6 +345,8 @@
},
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
+3 -1
View File
@@ -297,7 +297,9 @@ DERIVED = {
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
# PRICE-01: the price service token is a secret. It is set in the Configuration
# tab (a password field there) and this page never draws it.
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
def fields():
+4 -2
View File
@@ -18,8 +18,10 @@ from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
# A choice the page cannot make, only keep. None since P5's period editor.
ONLY_IF_HELD = ()
# A choice the page cannot make, only keep. `price_service` needs a token, and
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
# the Configuration tab and the page only keeps it (PRICE-01).
ONLY_IF_HELD = ("price_service",)
_TIERS = ("bad", "warn", "info")
+16
View File
@@ -1706,6 +1706,22 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
"choose 'off'.")
# PRICE-01. The price service answers only to its token. Without one every
# fetch is refused and the add-on lives on its fallbacks, which the
# operator would not see: a source they chose, overridden by a layer they
# cannot see. A token the server cannot read as a header is the same case.
if pid == "price_service":
token = str(opts.get("price_service_token", "") or "").strip()
if not token:
problems.append(
f"{S('price_provider')} is 'price_service', but "
f"{S('price_service_token')} is empty. Set the token, or "
"choose another source.")
elif any(c.isspace() or not c.isprintable() for c in token):
problems.append(
f"{S('price_service_token')} has a space or a line break in "
"it. Paste the token again, with nothing around it.")
return problems