CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
|
||||
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
try:
|
||||
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
|
||||
@@ -3521,6 +3575,15 @@ class Controller:
|
||||
or an outage would freeze the operator's static profile at the window
|
||||
it was generated for.
|
||||
|
||||
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
|
||||
WORTH SAYING. Since that card this task also runs on a site with a
|
||||
provider configured and trading DISARMED, so "keep the plan" is a
|
||||
decision about what the add-on has LOOKED UP, not about what it may
|
||||
trade on. It is also strictly downstream of `resolve_tariff()`: the
|
||||
§8.3 carry into `price_history` happens on the `series` branch inside
|
||||
that call, before this decision exists, so which of two plans is kept
|
||||
cannot add or lose a recorded day.
|
||||
|
||||
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
|
||||
fetch that produced the standing plan happened, and
|
||||
`fresh_tariff_plan()` judges the age from it - re-stamping it here
|
||||
@@ -3548,9 +3611,10 @@ class Controller:
|
||||
_LOG.warning(
|
||||
"tariff refresh degraded (%s); KEEPING the plan "
|
||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
||||
"so it retires %.0f s after that fetch and arbitrage "
|
||||
"stops with it - a kept plan is never presented as a "
|
||||
"current one.", plan.words, self.tariff_at,
|
||||
"so it retires %.0f s after that fetch; arbitrage, "
|
||||
"where it is armed, stops with it. A kept plan is "
|
||||
"never presented as a current one.", plan.words,
|
||||
self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
else:
|
||||
@@ -4416,6 +4480,14 @@ class Controller:
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# 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()),
|
||||
]
|
||||
@@ -4668,11 +4740,16 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||
# the tick, which is pure - so on a default install this task would be
|
||||
# network traffic in aid of an attribute nobody reads.
|
||||
if arbitrage.enabled(opts):
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
|
||||
if tariff_refresh_wanted(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||
|
||||
Reference in New Issue
Block a user