TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
checks / unit (pull_request) Successful in 2m43s
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.
Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.
"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.
Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.
A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.
validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.
CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.
Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.
All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
co-authored by
Claude Opus 5
parent
91ea676af4
commit
303525cfc5
@@ -1220,23 +1220,42 @@ check("a provider AND the Home Assistant price entities is refused - two price "
|
||||
"sources on one site", len(_p) == 1)
|
||||
check("...and the message says which one actually wins",
|
||||
why(_p, "HOME ASSISTANT ENTITIES WIN"))
|
||||
# TARIFF-03 added a second source with the same silent-loser shape.
|
||||
_ps = validate_startup_config(
|
||||
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
|
||||
'"days":"all","eur_per_kwh":0.31}]}',
|
||||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||||
-5000, 5000)
|
||||
check("a static_profile AND the Home Assistant price entities is refused too - "
|
||||
"main.py reads one of them for `cheap` and the loser is silent",
|
||||
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
|
||||
check("the Home Assistant entities alone still start, so nothing is taken away "
|
||||
"from a live install",
|
||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
-5000, 5000) == [])
|
||||
# ⚠️ A TRIPWIRE ON A REASON, NOT ON A BEHAVIOUR, AND IT IS AIMED AT WHOEVER
|
||||
# LANDS TARIFF-03 OR OPT-04. Rule 7 refuses "provider + HA entities" and its
|
||||
# stated reason is that the provider series decides NOTHING while the HA pair
|
||||
# quietly keeps deciding. The refusal stays correct forever; its EXPLANATION
|
||||
# expires the moment a consumer appears - at that point both really do decide,
|
||||
# and it becomes a genuine two-writer conflict rather than a silently-ignored
|
||||
# one. A future reader would then be trusting a reason that is no longer the
|
||||
# reason, which is the same defect TEST-06's boundary comment names.
|
||||
# ⚠️ THIS TRIPWIRE FIRED, AND THIS IS WHAT IT LOOKS LIKE AFTERWARDS.
|
||||
#
|
||||
# So: assert that nothing in app/ consumes the series yet. When you wire
|
||||
# load_prices() into the optimiser this goes RED, and the fix is to update the
|
||||
# refusal message in validate.py IN THE SAME COMMIT - not to delete this check.
|
||||
# TARIFF-02 left a check here asserting that NOTHING in app/ consumed
|
||||
# `load_prices()`, because rule 7's stated reason - "the supplier you configured
|
||||
# would decide nothing" - was true only for as long as that held. The
|
||||
# instruction attached to it was: when this goes red, update the message in
|
||||
# validate.py in the same commit, do not delete the check.
|
||||
#
|
||||
# TARIFF-03 landed `app/static_tariff.py`, whose `resolve_tariff()` consumes
|
||||
# `load_prices()` and turns a series into a tariff plan, a cheap-window flag and
|
||||
# an arbitrage verdict. The check went red, naming that file, and validate.py's
|
||||
# message was rewritten in the same commit to say what is true now instead. So
|
||||
# the tripwire is KEPT and RE-AIMED rather than removed, at the next thing that
|
||||
# can go quietly wrong:
|
||||
#
|
||||
# * the consumer set is PINNED. A second consumer appearing - OPT-04 wiring a
|
||||
# strategy in, most likely - goes red again, and whoever does that has to
|
||||
# re-read rule 7's message against what their change makes true.
|
||||
# * the EXPIRED PHRASE is asserted GONE. If the old wording is ever restored
|
||||
# by a bad merge, this says so.
|
||||
# * and the message has to keep naming its consumer, so it cannot decay into
|
||||
# a refusal with no stated reason at all.
|
||||
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
||||
# ⚠️ AST, NOT A SUBSTRING, AND THE FIRST DRAFT PROVED WHY. A text scan for
|
||||
# "load_prices" matched the COMMENT in validate.py that explains this very
|
||||
@@ -1264,10 +1283,22 @@ _consumers = derived(lambda: sorted(
|
||||
f for f in os.listdir(_APP)
|
||||
if f.endswith(".py") and f != "price.py"
|
||||
and _imports_or_calls(os.path.join(_APP, f), "load_prices")))
|
||||
check(f"no module consumes load_prices() yet, so rule 7's stated reason - 'the "
|
||||
f"provider decides nothing' - is still TRUE {_consumers}. When this goes "
|
||||
f"red, update that message in validate.py in the same commit",
|
||||
_consumers == [])
|
||||
_EXPECTED_CONSUMERS = ["static_tariff.py"]
|
||||
check(f"load_prices() is consumed by exactly the modules rule 7's message "
|
||||
f"names {_consumers}. When a SECOND one appears this goes red, and the "
|
||||
f"fix is to re-read that message against what the new consumer makes "
|
||||
f"true - not to widen this list without doing so",
|
||||
_consumers == _EXPECTED_CONSUMERS)
|
||||
_RULE7 = derived(lambda: open(os.path.join(_APP, "validate.py"),
|
||||
encoding="utf-8").read())
|
||||
check("...and the phrase the tripwire expired - 'would decide nothing' about "
|
||||
"the provider - is GONE from validate.py, so a bad merge that restores "
|
||||
"the old reason is red rather than quiet",
|
||||
isinstance(_RULE7, str)
|
||||
and "supplier you configured would decide nothing" not in _RULE7)
|
||||
check("...and the refusal still names the consumer that exists, so it cannot "
|
||||
"decay into a rule with no stated reason",
|
||||
isinstance(_RULE7, str) and "static_tariff.py" in _RULE7)
|
||||
# ...and the other half: the registry IS on the startup path, which is what
|
||||
# makes an unwired seam anchored rather than dead code.
|
||||
check("...while validate.py does import the registry, so the seam is exercised "
|
||||
|
||||
Reference in New Issue
Block a user