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
+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))