Files
glenn schrooyenandClaude Opus 5 bdd7f18a54
checks / unit (pull_request) Successful in 3m16s
OPT-04: charge cheap, discharge expensive - and only when it pays
Four cards built the price stack and nothing traded on it. app/arbitrage.py is
§10.1's priority 3, and it consumes all four rather than re-deciding anything:
whether a trade is permitted at all, on which two hours, against which widened
threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be
obtained without first saying whether the peak guard is holding. §9.1's "peak
shaving outranks arbitrage" is therefore honoured structurally - there is no
path to a spread that skips the question - and again in the arbiter, where
P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30).

A guard that never ran is not a guard that is clear. On meter_source off the
peak guard is never evaluated; that third state is carried as None and refuses,
because charging at the rail creates the billed quantity while nothing measures
the quarter. validate.py refuses that configuration too, along with arbitrage
armed with no price source and a SoC floor at or above the ceiling.

§17: never export into a negative price. The guard is on the hour being sold
into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and
its dearest hour is still negative. Buying in a negative hour stays allowed.

resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32
heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick
reads an attribute and an age bound. test_arbitrage.py section 8 asserts that
structurally on the shipped AST: a fetch put back on cycle() is red.

AC4 is NOT met and is not restated - this repository holds no price history.
Section 7 replays a synthetic year and asserts the property that does not need
real prices: every executed round trip cleared its own widened requirement, and
zero discharges landed in the 522 negative-priced hours. At the shipped 0.10
EUR/kWh threshold a realistic Belgian day/night site does not trade at all.
Reported, not tuned: that number and FCST-03's widening factors are one product
decision and it is the owner's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 01:36:09 +02:00

430 lines
21 KiB
Python

"""OPT-04 - §10.1 priority 3: charge cheap, discharge expensive, and only when
the round trip is worth more than it costs.
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE CARD SAYS. The functional notes
on the Trello card reference specs.md §2.2's `ems/optimizer/...` layout. That
module layout does not exist and is not being built; the product owner's
decision of 2026-08-30 is that everything ships in `goodwe_controller/app/`,
beside price.py, static_tariff.py, peakguard.py and arbiter.py. The BEHAVIOUR
§10.1 / §9.1 / §8.3 / §17 describes is unchanged and still authoritative.
WHAT THIS MODULE IS, AND - MORE IMPORTANTLY - WHAT IT IS NOT
============================================================
Four cards landed before this one and this one CONSUMES all four. Almost every
question an arbitrage strategy has to answer is already answered somewhere
else, by an object built for it, and the single biggest way to get this ticket
wrong is to answer any of them again here:
price.py (TARIFF-02) what a price IS, all-in, and what a product is.
`Uncertainty`, `WIDEN_FACTOR`, `required_spread`.
static_tariff.py (TARIFF-03) `TariffPlan.arbitrage()` - THE VERDICT. Whether
a trade is permitted at all, on which two hours,
against which widened threshold. Five refusal
reasons live there, not here.
price_forecast.py (FCST-03) provisional carrying, and the rule that no
irreversible action rests on a provisional
price (§8.3).
peakguard.py (SAFETY-07) the capacity-tariff floor, filed as a `lo`
LIMIT at P_PEAK on the arbiter.
So what is left for this file is genuinely small, and that is the point:
* WHICH HOUR IS IT NOW - is this instant the buy hour, the sell hour, or
neither.
* MAY THE BATTERY DO IT - state of charge, and §17's negative-price rule.
* HOW HARD - one number, filed as one arbiter claim.
Everything else is delegated, and delegated by CONSTRUCTION rather than by
convention: `TariffPlan.arbitrage()` will not hand over a spread at all unless
it is told whether the peak guard is holding, what a cycle costs, and what is
uncertain. There is no code path here that can reach a tradeable number without
having answered §10.1's priority ordering first.
AC3 - PEAK SHAVING WINS, AND IT WINS TWICE
==========================================
§9.1: "Give peak shaving priority over arbitrage whenever the two conflict."
That is honoured through the two mechanisms that already exist, and NEITHER of
them is re-implemented here:
1. THE INTERLOCK. `plan.arbitrage(peak_guard_active=...)` is keyword-only and
REQUIRED. When the guard is engaged the verdict is a refusal carrying the
§10.1 wording, so `decide()` below never sees a spread at all. It is not
possible to write a caller here that skips the question - the argument has
no default and the spread is unreachable without it.
2. THE ARBITER. Even if (1) were somehow bypassed, `claim()` files a SET at
P_ARBITRAGE (30) and peakguard files a LIMIT at P_PEAK (50). arbiter.py
clause 2 applies every limit whose priority is >= the winning set's, most
restrictive wins - so the peak floor CLAMPS an arbitrage charge whatever
this module thinks. 50 >= 30 is what makes that true, and it is why the
priority below sits under P_PEAK rather than beside it.
Two independent mechanisms for one rule is deliberate. The first is the one
that reports honestly; the second is the one that holds when the first is
wrong.
⚠️ AND THE GUARD MUST BE ABLE TO SEE. On `meter_source: off` there is no
telegram stream, so peakguard.decide() is never run and `peak_guard_active` is
not FALSE - it is UNKNOWN. Charging a battery at max_w creates offtake, offtake
is the billed quantity (§9.1), and doing it deliberately while nothing measures
the quarter is the exact shape of failure §9.1 exists to prevent. So the third
state is carried explicitly as `None` and it REFUSES. A failed read is not an
observation, and "the guard did not run" is not "the guard is clear".
§17's NEGATIVE-PRICE CASE, STATED ONCE
======================================
"Negative-price hours - arbitrage must not export." A negative price means the
market pays the site to consume. Discharging the battery into such an hour
gives away stored energy AND forgoes being paid to take more in; if the house
load is covered it becomes export, which at a negative price is paid-for
disposal. The buy side is untouched - a negative hour is the best possible hour
to charge in, and this file's guard is deliberately asymmetric for that reason.
⚠️ THE GUARD IS ON THE HOUR BEING SOLD INTO, NOT ON THE SPREAD. A series whose
prices are -0.30 and -0.05 has a 0.25 EUR/kWh spread that clears any shipped
threshold, and its DEAREST hour is still negative. A guard written as
"spread > 0" or "the prices are not all negative" passes that case and exports.
WHAT `cycle_cost_threshold_eur` IS, AND WHAT THIS FILE REFUSES TO DO WITH IT
===========================================================================
§10.1 gates arbitrage on "expected round-trip benefit exceeds
`cycle_cost_threshold_eur`". It ships at 0.10 EUR/kWh. On realistic Belgian
day/night numbers the static spread is about 0.08 EUR/kWh, and FCST-03's
widening factors (2.5 real-time, 1.5 no-weather) are honest policy defaults
with nothing to derive them from.
⚠️ SO A STATIC-TARIFF SITE DOES NOT TRADE AT SHIPPED DEFAULTS, AND THAT IS
REPORTED RATHER THAN TUNED AWAY. The threshold is a parameter here, the
behaviour is correct at any value of it, and no number in this file was chosen
to make a trade happen. Whether 0.10 is the right shipped default - and whether
2.5/1.5 are - is one product decision and it belongs to the owner, not to this
card. test_arbitrage.py section 7 reports what the shipped default does over a
replayed year rather than adjusting anything so that it looks better.
THE ACCOUNTING BASIS, AND THE TERM THAT IS DELIBERATELY ABSENT
=============================================================
The card's technical notes ask for `benefit = value_out(d) - cost_in(c)/η`, and
then say of the round-trip efficiency: "η is NOT a §14 key - it needs one, or
must be derived from measured charge/discharge energy in the state store. DO
NOT HARDCODE IT." Both halves are honoured by not having the term at all:
* THE SELL SIDE IS PRICED AS DISPLACED IMPORT, NOT AS EXPORT. The two hours
compared are both all-in CONSUMPTION prices, which is the basis the notes
require ("credit displacement at all-in import prices, not at export price,
or the number flatters itself"). `export_price_eur_kwh` is therefore not
read here at all - §9.3's near-zero Belgian injection price is precisely
why the economically sound trade on this market is charge-cheap-to-
displace-expensive-import, and §17's guard below is the same fact stated as
a rule.
* AND THERE IS NO η, BECAUSE THERE IS NOTHING HONEST TO PUT IN IT. No config
key exists, the state store carries no measured charge/discharge energy
pair to derive one from, and a hardcoded 0.85 is exactly what the notes
forbid - it would also DOUBLE-COUNT, because `cycle_cost_threshold_eur` is
already defined as what a round trip costs per kWh and conversion loss is
part of that cost. When a measured η exists it belongs beside the threshold
as a second term, and this file's arithmetic is one subtraction, in one
place, ready for it.
HYSTERESIS (§10.2)
==================
The switching decision is "which hour is it", and an hour is 60 minutes wide,
so the action cannot chatter within one. The other two terms are monotone
inside an hour in the only direction that matters: a charge raises SoC towards
the ceiling and a discharge lowers it towards the floor, so each can stop once
and cannot restart in the same hour. No timer is needed and none is invented.
"""
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from .arbiter import P_ARBITRAGE, Claim
from .price import Absence, price_option
_LOG = logging.getLogger("goodwe.arbitrage")
SOURCE = "arbitrage"
# §10.2 reason codes. Written out because the action log is a user-facing
# artefact and a code assembled from an f-string cannot be grepped for.
CHARGE = "ARBITRAGE_CHARGE"
DISCHARGE = "ARBITRAGE_DISCHARGE"
# Why nothing was done. These are the reasons this MODULE owns; the five
# refusals that belong to the verdict itself are carried through verbatim from
# static_tariff.py rather than restated, so a refusal's wording has exactly one
# author.
IDLE_NO_PLAN = "no tariff plan has been resolved yet"
IDLE_NOT_COVERED = (
"NOT COVERED: meter_source is off, so the capacity-tariff peak guard never "
"runs and nothing measures the quarter this charge would land in. §9.1 puts "
"peak shaving above arbitrage, and an unmeasured peak cannot be shaved - "
"so the honest answer is that arbitrage is not covered on this install, not "
"that the guard is clear")
IDLE_NO_SOC = (
"state of charge is not being read, and a battery whose fill nobody knows "
"cannot be told to empty or to fill")
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
"negative price")
@dataclass(frozen=True)
class ArbitrageDecision:
"""What the strategy concluded this cycle, with what it concluded it from.
⚠️ NO FIELD HAS A DEFAULT, and that is TEST-14's rule rather than a style
choice: an outcome type's required field quietly gaining a default is a
mutation that survived review in three files in this repository. The
signature is scanned in test_price_forecast.py alongside `Gate`'s and
`Arbitrage`'s constructors, so a default added here later goes red without
anybody remembering to write a case for it.
`action` is None whenever nothing is to be done, and `power_w` is then None
too - not 0.0. Zero watts is a COMMAND (it parks the battery); "no claim"
is the absence of one, and the arbiter must be free to let the loop win.
"""
action: str | None # CHARGE | DISCHARGE | None
power_w: float | None # signed: negative charges, positive discharges
reason: str
spread_eur_kwh: float | None
required_eur_kwh: float | None
price_now_eur_kwh: float | None
@property
def trading(self) -> bool:
return self.action is not None
def explain(self) -> str:
if not self.trading:
return f"arbitrage idle - {self.reason}"
return self.reason
def _idle(reason, *, spread=None, required=None, price=None) -> ArbitrageDecision:
if not reason or not isinstance(reason, str):
raise ValueError(
"an idle arbitrage decision needs a reason a human can act on - "
"§10.2 requires every plan change to carry one, and 'arbitrage did "
"nothing' with no reason is indistinguishable from a bug.")
return ArbitrageDecision(action=None, power_w=None, reason=reason,
spread_eur_kwh=spread, required_eur_kwh=required,
price_now_eur_kwh=price)
def enabled(opts: dict) -> bool:
"""Is the strategy switched on at all? Off in the shipped configuration.
⚠️ OFF BY DEFAULT ON PURPOSE. Every other card in this repository ships its
machinery inert and lets an operator arm it; this one MOVES ENERGY against
the meter on a real battery, so it is the last one that should be an
exception to that.
"""
v = price_option(opts, "arbitrage_enabled")
return v is True or str(v).strip().lower() in ("true", "yes", "on", "1")
def threshold_eur_kwh(opts: dict) -> float:
"""§10.1's `cycle_cost_threshold_eur`, from the one place option defaults live."""
v = float(price_option(opts, "cycle_cost_threshold_eur"))
if not math.isfinite(v) or v < 0:
raise ValueError(
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
f"in EUR/kWh, so it is non-negative and finite - a negative "
f"threshold is one every spread clears, which is arbitrage with the "
f"brake removed.")
return v
def soc_bounds(opts: dict) -> tuple:
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
return (float(price_option(opts, "arbitrage_soc_floor")),
float(price_option(opts, "arbitrage_soc_ceiling")))
def guard_state(peak_decision):
"""The peak guard's answer as the three-state `decide()` takes: T / F / None.
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
`peakguard.shaping_suspended()`, `watchdog.loop_is_following()` and
`freeze.looks_frozen()` are: the check that grades it has to assert on THE
SHIPPED EXPRESSION. A predicate transcribed into its own test is one copy
plus a future disagreement about what the check means, and the copy that
drifts is always the grading one - this repository has three separate
records of exactly that, one of which left every suite green while a
default install parked at 0 W in the quarter that sets the month's bill.
⚠️ `None` IS NOT `False` AND COLLAPSING THE TWO IS THE WHOLE HAZARD. On
`meter_source: off` main.py never runs peakguard.decide() - there is no
telegram stream, so there is no open quarter to project - and the decision
it hands here is None. That is "the guard never ran", not "the guard is
clear", and answering False would arm a strategy whose charging creates the
billed quantity (§9.1) while nothing measures the quarter it lands in.
"""
if peak_decision is None:
return None
return bool(peak_decision.engaged)
def _point_at(plan, when: datetime):
"""The price point covering `when`, or None when the series does not cover it.
⚠️ None IS NOT A PRICE AND IS NOT ZERO. `TariffPlan` raises `Absence` here
rather than answering 0.0, for price.py's reason - "no data" becoming "free"
is the defect the whole outcome-type family exists to remove - and this
turns that into "do nothing", never into "it is cheap".
"""
try:
return plan.read.match(series=lambda r: r.at(when),
no_data=lambda _r: None)
except Absence:
return None
def decide(plan, now: datetime, *, peak_guard_active, soc,
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
"""Should the battery trade this cycle, and how hard.
⚠️ `peak_guard_active` IS A THREE-STATE ARGUMENT AND ALL THREE ARE REAL.
True (the guard is holding), False (it ran and is clear), and None (it never
ran, because nothing measures this install's quarter). See the module note:
None refuses, because the alternative is a strategy that creates the billed
quantity while the thing that would have limited it is switched off.
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
floor this module asserted rather than one the operator configured, and the
first time it is wrong the battery is at 0 %.
"""
if peak_guard_active is None:
return _idle(IDLE_NOT_COVERED)
if plan is None:
return _idle(IDLE_NO_PLAN)
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
min_spread_eur_kwh=min_spread_eur_kwh,
uncertainty=uncertainty)
return verdict.match(
refused=lambda v: _idle(v.reason),
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct))
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
"""The permitted case: which hour is this, and may the pack do it."""
here = _point_at(plan, now)
if here is None:
return _idle(
f"the series carries a tradeable spread but no price for this "
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
f"than a reason to reach for a neighbouring one",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
price = here.eur_per_kwh
buying = here.start == v.cheapest.start
selling = here.start == v.dearest.start
if not (buying or selling):
return _idle(IDLE_OFF_PEAK_HOUR, spread=v.spread_eur_kwh,
required=v.required_spread_eur_kwh, price=price)
# ⚠️ BELT AND BRACES ON §8.3, AND IT IS NOT DEAD CODE. `_firm_or_die` makes
# the two points on the verdict firm at construction, but the point matched
# HERE is whatever `at()` returned for this instant, and the identity of the
# two rests on the series being the one the verdict was built from. An hour
# that is provisional is one nobody published a price for, and no
# irreversible action may rest on it.
if here.provisional:
return _idle(
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
f"CARRIED FORWARD, not published, and no irreversible action may be "
f"taken on a provisional price",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
price=price)
if soc is None:
return _idle(IDLE_NO_SOC, spread=v.spread_eur_kwh,
required=v.required_spread_eur_kwh, price=price)
common = dict(spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
price=price)
if selling:
# §17. THE NAMED REGRESSION CASE. Checked BEFORE the SoC question, so a
# negative price reports the reason that actually forbids the trade
# rather than one that happens to fire first on some other site.
if price < 0:
return _idle(f"{NEGATIVE_PRICE} - this hour is "
f"{price:+.4f} EUR/kWh", **common)
if soc <= floor_pct:
return _idle(
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
f"arbitrage may not spend (§10.1 priority 4)", **common)
return ArbitrageDecision(
action=DISCHARGE, power_w=abs(float(discharge_w)),
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
f"this cycle had to cover; discharging "
f"{abs(float(discharge_w)):.0f} W from {soc:g} %"),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
if soc >= ceiling_pct:
return _idle(
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
f"nowhere to put the cheap energy", **common)
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
reason=(f"{CHARGE}: this is the cheapest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
f"{soc:g} %"),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
def claim(decision: ArbitrageDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when nothing is to be traded.
A SET at P_ARBITRAGE, and the priority is the whole §10.1 ordering:
P_PEAK 50 > this, so peakguard's `lo` floor CLAMPS an arbitrage
charge (arbiter clause 2 applies every limit at or
above the winner's priority). AC3, mechanically.
P_MAINT_SHAPE 40 > this, so a maintenance charge window's `hi` shaping is
not overridden by a price opportunity. §12.4's "money
outranks the schedule" is about the PEAK guard, which
is measured and billed; a speculative spread is not
that, and letting it out-rank the pack's balancing
cycle would be this card quietly rewriting §12.
P_LOOP 10 < this, because trading against the meter is the point:
the grid-following loop would otherwise cancel every
charge the moment it created import.
⚠️ NONE MEANS NO CLAIM, NOT A 0 W CLAIM. Filing `Claim.set(..., 0.0)` here
would out-rank the loop with a park order every cycle the strategy had
nothing to say, i.e. it would silently disable self-consumption on any site
that armed arbitrage.
"""
if not decision.trading or decision.power_w is None:
return None
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
decision.action)