PRICE-01: price_service provider, fallback chain, and the gate read from it
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:
co-authored by
Claude
parent
0ce7d35f80
commit
9ac3a9d2bc
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user