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
@@ -157,6 +157,7 @@ import logging
|
||||
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .price import PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
@@ -410,8 +411,13 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Three refusals, all case (a) from this module's docstring: a source the
|
||||
operator SET being overridden by a layer they cannot see.
|
||||
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices), and every refusal is still case (a) from this module's
|
||||
docstring: a source the operator SET being overridden by a layer they cannot
|
||||
see. The additions are `static_profile` alongside the Home Assistant price
|
||||
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
||||
and a `static_profile` that does not tile the day, which would leave hours
|
||||
with no price at all.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
||||
@@ -426,24 +432,35 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
return problems # nothing below can be judged against an unknown id
|
||||
|
||||
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
||||
# price_now_entity/price_avg_entity are the existing delegation of pricing
|
||||
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read only
|
||||
# by the maintenance cheap-window charge floor. They are KEPT (TARIFF-03 and
|
||||
# OPT-04 are what replace them, and neither has landed), but they may not
|
||||
# run alongside a provider. With both configured the provider series drives
|
||||
# nothing at all while the two entities quietly keep deciding, and the
|
||||
# operator's evidence that they picked a supplier is a status line saying
|
||||
# they picked a supplier. That is (a), and it is the "silently double up"
|
||||
# the PM named on 2026-08-30.
|
||||
# ⚠️ THIS REFUSAL'S REASON EXPIRES, AND THE MESSAGE BELOW MUST BE EDITED IN
|
||||
# THE SAME COMMIT THAT LANDS THE FIRST CONSUMER (TARIFF-03 or OPT-04).
|
||||
# Today the message says the provider "would decide nothing" - true, because
|
||||
# nothing reads the series. Once something does, both sources really decide
|
||||
# and this becomes a straightforward two-writer conflict; the refusal stays
|
||||
# right and its explanation goes wrong, which is worse than either. There is
|
||||
# a tripwire on exactly this in test_price.py section 12: it greps app/ for
|
||||
# a `load_prices` consumer and goes RED the moment one appears. Update the
|
||||
# message, not the check.
|
||||
# price_now_entity/price_avg_entity are the original delegation of pricing
|
||||
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
|
||||
# the maintenance cheap-window charge floor. They are KEPT - retiring them
|
||||
# would take a live install's only price signal away - but they may not run
|
||||
# alongside another price source, because main.py reads exactly one and the
|
||||
# loser is silent. That is (a), and it is the "silently double up" the PM
|
||||
# named on 2026-08-30.
|
||||
#
|
||||
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
|
||||
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
|
||||
# "would decide nothing", which was true only because nothing consumed the
|
||||
# series; test_price.py section 12 carried a tripwire that went red the
|
||||
# moment a consumer landed, with instructions to fix the wording here in the
|
||||
# same commit. It landed, it went red, and this is that fix. What is true
|
||||
# NOW, stated exactly and no wider:
|
||||
#
|
||||
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
|
||||
# provider series into a TariffPlan, a cheap-window flag and an
|
||||
# arbitrage verdict, so the seam is no longer inert.
|
||||
# * main.py reads the STATIC side of that module every cycle
|
||||
# (static_status()), which makes static_profile a real writer of `cheap`.
|
||||
# * main.py does NOT yet call resolve_tariff() on the control tick, and
|
||||
# that is deliberate: a provider fetch is network I/O and the tick is
|
||||
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
|
||||
# no energy decision on its own; a configured STATIC PROFILE does.
|
||||
#
|
||||
# Hence two refusals below rather than one, and neither claims more than
|
||||
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
|
||||
# first message's middle clause is what changes - not the rule.
|
||||
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if pid != "off" and ha_price:
|
||||
@@ -452,12 +469,65 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
||||
"reads, and nothing yet consumes the provider series, so the "
|
||||
"supplier you configured would decide nothing while the status "
|
||||
"page named it. Clear price_now_entity and price_avg_entity to use "
|
||||
f"the {pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||
"reads. The provider series IS consumed now - app/static_tariff.py "
|
||||
"builds a tariff plan and an arbitrage verdict from it - but it is "
|
||||
"not what main.py acts on, so the supplier you configured would "
|
||||
"shape no energy decision while the status page named it. Clear "
|
||||
"price_now_entity and price_avg_entity to use the "
|
||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||
"Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
||||
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
||||
# from the Home Assistant pair when it is set and from the static profile
|
||||
# otherwise - one flag, first writer wins, and the operator who filled in a
|
||||
# day/night tariff has no way to see that it lost.
|
||||
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||
problems.append(
|
||||
f"static_profile is set AND {' and '.join(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
||||
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
||||
"ENTITIES WIN: main.py reads them first and never looks at the "
|
||||
"static profile, so the day/night tariff you typed would decide "
|
||||
"nothing and nothing would say so. Clear price_now_entity and "
|
||||
"price_avg_entity to use static_profile, or clear static_profile "
|
||||
"to keep the Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
||||
# that does not tile the day has hours with no price, and the only ways to
|
||||
# serve those are to invent a number or to fail at the moment the clock
|
||||
# reaches them. §14's warning box - refuse to start on a mismatch rather
|
||||
# than clamp quietly - is the same instruction one level down.
|
||||
raw_profile = opts.get("static_profile", "")
|
||||
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
|
||||
try:
|
||||
profile = parse_static_profile(raw_profile)
|
||||
except ProfileInvalid as e:
|
||||
profile = None
|
||||
problems.append(f"static_profile cannot be used: {e}")
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
"static_profile carries one distinct price (%.4f EUR/kWh "
|
||||
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
||||
"off by arithmetic - a flat series has no spread - and the "
|
||||
"add-on runs self-consumption only, which is §9.2's handling "
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
# is a real gap worth naming - but refusing to start over it would break
|
||||
# every install TARIFF-02 shipped, none of which could have had one.
|
||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||
_LOG.warning(
|
||||
"price_provider is %r but static_profile is empty. §11.4's "
|
||||
"degradation row is 'no price data -> fall back to the static "
|
||||
"profile'; with none configured, a feed outage leaves this site "
|
||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||
"but filling it in is what makes that row work.", pid)
|
||||
|
||||
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
||||
problems.append(
|
||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
||||
|
||||
Reference in New Issue
Block a user