Files
goodwe-addon/goodwe_controller/app/arbitrage.py
T
glenn schrooyenandClaude Opus 5 f8e501393f OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00

449 lines
22 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 ACTUATOR_SITE, 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"
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
# and are now SIZED to the packs whose state of charge permits that direction, so
# a caller can hand this function a zero. A zero must not reach `claim()`: a
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
# which is the failure the note on `claim()` is written against - it would
# silently disable self-consumption for the whole hour. So the sizing is checked
# where the decision is made rather than trusted to arrive positive.
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
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)
if not abs(float(discharge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **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)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **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
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. A trade is bought and sold at the
# grid: the price is a property of the connection and the energy crosses the
# meter. So this is a claim on the SITE target, which the allocator then
# divides - not an instruction to any one pack.
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
decision.action, actuator=ACTUATOR_SITE)