TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
+160
-10
@@ -218,6 +218,106 @@ def pack_energy_of(inv):
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
provider: bool, max_age_s: float) -> str:
|
||||
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon,
|
||||
the firm/carried split and the spacing appeared in NO status row. The row
|
||||
was `static_status()`'s sentence alone, computed on the 30 s I/O-free tick
|
||||
from its own static plan, and it NEVER SAW the provider plan - it read
|
||||
`'no static profile'` at all 802 samples of a 40-hour soak on a site whose
|
||||
feed was answering. The only external evidence a plan existed at all was
|
||||
one `_LOG.info` line, and that line carried the point<->hour mislabel
|
||||
`TariffPlan.words` is fixed for.
|
||||
|
||||
⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences
|
||||
are NOT computed at the same moment and presenting them as though they
|
||||
were would be a new version of the defect being fixed:
|
||||
|
||||
`tick_words` static_status() / the Home Assistant price entities,
|
||||
regenerated on THIS tick, every 30 s, I/O-free;
|
||||
`plan` the background refresh's resolution, up to
|
||||
TARIFF_REFRESH_S old before it is retried and up to
|
||||
TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before
|
||||
`fresh_tariff_plan()` retires it.
|
||||
|
||||
So the plan half is stamped with its OWN age in seconds, measured from
|
||||
`tariff_at` - the moment of the fetch that produced it, which TARIFF-12
|
||||
deliberately does not move on a kept plan.
|
||||
|
||||
⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock
|
||||
read in here would be a second clock disagreeing with the one that decided
|
||||
the plan was fresh enough to pass at all.
|
||||
|
||||
⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A
|
||||
dynamic site whose refresh has never completed, or whose plan has aged
|
||||
past `max_age_s`, is the exact state the soak measured; rendering it as
|
||||
the bare static sentence is how it stayed invisible for 802 samples.
|
||||
"""
|
||||
if plan is None:
|
||||
if not provider:
|
||||
return tick_words
|
||||
return (f"{tick_words} (recomputed this tick); NO provider plan - "
|
||||
f"none has been resolved yet, or the last one is older than "
|
||||
f"{max_age_s:.0f} s")
|
||||
return (f"{tick_words} (recomputed this tick); provider plan fetched "
|
||||
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
|
||||
|
||||
|
||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
why: str) -> str:
|
||||
"""The *Arbitrage* status row: THREE states, which used to render as two.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). The row was
|
||||
`"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`,
|
||||
under a comment asserting that `self.arb is None` if and only if
|
||||
`arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the
|
||||
input hold - inputs missing, `bad_for < stale_input_s`, default 15 s -
|
||||
BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true`
|
||||
plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`,
|
||||
`self.arb = None` (its `__init__` value, untouched) and a row reading
|
||||
`off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first
|
||||
completed cycle the same early return leaves the PREVIOUS decision
|
||||
standing instead, which the row then rendered as a current one.
|
||||
|
||||
THE THREE FACTS, and they are three because they have three remedies:
|
||||
|
||||
trading is off the operator has not armed it. Nothing is
|
||||
wrong and nothing will happen.
|
||||
no decision this cycle it is armed, and the cycle did not get as far
|
||||
as the arbitrage step. `why` is the control
|
||||
loop's own reason - usually "inputs missing".
|
||||
a decision the strategy's own sentence, with the age of
|
||||
the cycle that produced it.
|
||||
|
||||
⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`.
|
||||
Inferring it is the whole defect: the option is the only thing that
|
||||
answers "is trading armed", and it is available on every render.
|
||||
|
||||
⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that
|
||||
reports staleness only once it is stale cannot be told, on any single
|
||||
render, from one that never reports it - and the bound TARIFF-10's
|
||||
comment claimed did not exist is ~15 s of it.
|
||||
"""
|
||||
if not on:
|
||||
return "off - `arbitrage_enabled` is false, so nothing trades"
|
||||
if decided_at is None:
|
||||
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
|
||||
f"yet - no cycle has run as far as the arbitrage step. The "
|
||||
f"loop's own reason: {why or 'none recorded'}")
|
||||
age = (now - decided_at).total_seconds()
|
||||
stale = cycle_at is not None and decided_at < cycle_at
|
||||
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
|
||||
f"last one was taken {age:.0f} s ago: " if stale else
|
||||
f"decided {age:.0f} s ago: ")
|
||||
if decision is None:
|
||||
return (f"ON (`arbitrage_enabled`), no decision this cycle "
|
||||
f"({why or 'no reason recorded'}), and none stands from an "
|
||||
f"earlier one")
|
||||
return head + decision.explain()
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
@@ -796,6 +896,19 @@ class Controller:
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.arb = None
|
||||
# TARIFF-11. WHEN the standing arbitrage decision was taken, or None if
|
||||
# this controller has never reached the arbitrage step. ⚠️ `self.arb is
|
||||
# None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see
|
||||
# `arbitrage_row()`. It also means "cycle() returned before the step
|
||||
# ran", and at that point `self.arb` is either this attribute's initial
|
||||
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
|
||||
# the status row rendered all three identically.
|
||||
self.arb_at = None
|
||||
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
|
||||
# is the exact, derived statement of "the latest cycle did not get as
|
||||
# far as the arbitrage step", which is the fact the *Arbitrage* row
|
||||
# used to render identically to "trading is switched off".
|
||||
self.cycle_at = None
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
# SAFETY-03. What the loop's OWN bounds did last cycle - a
|
||||
@@ -1565,6 +1678,24 @@ class Controller:
|
||||
self.cheap, self.tariff_words = static_status(
|
||||
o, datetime.now(timezone.utc))
|
||||
|
||||
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
|
||||
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
|
||||
# 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an
|
||||
# age comparison against TARIFF_MAX_AGE_S - still no fetch on this path,
|
||||
# which is the rule the whole branch above exists to keep. What the row
|
||||
# gains is the plan's SHAPE (origin, product, horizon, the firm/carried
|
||||
# split, the spacing) and what it must never gain is the suggestion
|
||||
# that the two halves were computed at the same instant, so the plan
|
||||
# half carries its own age in seconds. See `tariff_plan_row()`.
|
||||
_tick = datetime.now(timezone.utc)
|
||||
_standing = self.fresh_tariff_plan(_tick)
|
||||
self.tariff_words = tariff_plan_row(
|
||||
_standing, self.tariff_words,
|
||||
age_s=(None if _standing is None
|
||||
else (_tick - self.tariff_at).total_seconds()),
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered")
|
||||
@@ -1918,6 +2049,11 @@ class Controller:
|
||||
method's only job is to state honestly what each strategy wants.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
# ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11).
|
||||
# It stamps that a cycle RAN, which is a different fact from any of the
|
||||
# decisions the cycle may or may not reach, and the status row needs
|
||||
# the two separately.
|
||||
self.cycle_at = now
|
||||
|
||||
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so each
|
||||
@@ -2577,7 +2713,13 @@ class Controller:
|
||||
# `frozenset()` would be main.py asserting a forecast is healthy when
|
||||
# there is not one, which is exactly what static_tariff's `uncertainty`
|
||||
# argument has no default in order to prevent.
|
||||
self.arb = None
|
||||
# ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW
|
||||
# HONEST. Reaching this line is the only proof that this cycle got as
|
||||
# far as the arbitrage step; the early return at the input hold above
|
||||
# leaves BOTH standing at whatever the last completed cycle left, and
|
||||
# `arb_at` is then the age the status row reports rather than a
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
@@ -4481,15 +4623,23 @@ class Controller:
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
#
|
||||
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
|
||||
# saying because that card is about the two switches being
|
||||
# different. `self.arb` is None if and only if
|
||||
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
|
||||
# still names the option that is actually off - and on a site with a
|
||||
# provider configured it is now the row that says the prices
|
||||
# arriving on the *Price history* row above are moving nothing.
|
||||
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
|
||||
else self.arb.explain()),
|
||||
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A
|
||||
# COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The
|
||||
# claim was that `self.arb is None` **if and only if**
|
||||
# `arbitrage.enabled(self.o)` was False. Only the forward direction
|
||||
# holds: `cycle()` returns at the input hold (`bad_for <
|
||||
# stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None`
|
||||
# at all, so on an armed site `self.arb` is `__init__`'s None at
|
||||
# startup and the PREVIOUS cycle's decision during a later hold -
|
||||
# and the row rendered `off (arbitrage_enabled)` on a site whose
|
||||
# `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option
|
||||
# instead of inferring it, and separates "trading is off" from "no
|
||||
# decision was reached this cycle"; `cycle_at` and `arb_at` are what
|
||||
# make the second one an observation rather than a guess.
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o), decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.reason)),
|
||||
]
|
||||
return {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
|
||||
Reference in New Issue
Block a user