TariffPlan.arbitrage(now=...) drops the points whose period has ended (end <= now), so the cheapest and dearest point can still be traded. decide() (modes off), the modes-on allowed() gate and the Home verdict all pass their own now. Hourly feeds and the static tariff are unchanged. The off-peak sentence and the charge/discharge sentences say "price period". Tests: test_price02.py; test_arbitrage.py rolls its whole-day plans to start at now; test_modes_p3.py part 1 digests leave the trade sentence out (commands unchanged on7a00f12,90bc901and this tree). Co-Authored-By: Claude <noreply@anthropic.com>
564 lines
29 KiB
Python
564 lines
29 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 = "now is not the cheapest or the dearest price period"
|
|
# 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")
|
|
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
|
|
# export are never capped by the peak guard; only charging and import are."
|
|
IDLE_NO_HEADROOM = (
|
|
"the capacity-tariff guard has measured this billed quarter and it can "
|
|
"afford no charging at all, so a trade of any size would push it over the "
|
|
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
|
|
IDLE_HEADROOM_UNMEASURED = (
|
|
"the capacity-tariff guard did not size this quarter's charging headroom "
|
|
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
|
|
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
|
"this one would land in")
|
|
NEGATIVE_PRICE = (
|
|
"§17: the sell period 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"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
|
|
f"it to 0 or more, in EUR/kWh.")
|
|
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, charge_headroom_w, 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.
|
|
|
|
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
|
|
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
|
|
SWITCH - it says whether the guard is holding - and a switch keyed on a
|
|
continuous quantity is what made the loop oscillate: the guard engaged at a
|
|
5 W floor, this module refused the whole trade, the loop took the actuator,
|
|
the meter fell, the guard released and the trade re-armed AT FULL POWER
|
|
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
|
|
the watts the billed quarter can still afford; sizing the buy leg to it means
|
|
the trade is CLAMPED rather than switched, which is the same shape
|
|
`control.maintenance_charge_floor()` has always given a maintenance charge.
|
|
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
|
|
|
|
⚠️ 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, now=now)
|
|
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,
|
|
charge_headroom_w))
|
|
|
|
|
|
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
|
headroom_w):
|
|
"""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 period 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 period ({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 period is "
|
|
f"{price:+.4f} EUR/kWh", **common)
|
|
if soc <= floor_pct:
|
|
return _idle(
|
|
f"the sell period 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 price period 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 period 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)
|
|
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
|
|
# DELIBERATE: every refusal that existed before this card keeps the
|
|
# precedence it had, so no sentence an operator already reads changes when
|
|
# it fires, and the two added here are placed where they cannot displace
|
|
# one. The pack's reasons come first because they are properties of the
|
|
# hardware in the cupboard; this one is a property of the fifteen minutes
|
|
# the trade would land in.
|
|
if headroom_w is None:
|
|
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
|
|
# one layer down: a headroom nothing measured is not an unlimited one,
|
|
# and charging at max_w creates the billed quantity (§9.1).
|
|
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
|
|
asked_w = abs(float(charge_w))
|
|
size_w = min(asked_w, max(0.0, float(headroom_w)))
|
|
if not size_w > 0.0:
|
|
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
|
|
# `asked_w > 0` is already established here, so the pack may be empty
|
|
# and willing and it is the billed quarter that has run out. A refusal
|
|
# naming the wrong subsystem sends an operator to the wrong page
|
|
# (§10.2), and these two are a whole quarter-hour apart in what they
|
|
# ask a human to do about them.
|
|
return _idle(IDLE_NO_HEADROOM, **common)
|
|
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
|
|
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
|
|
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
|
|
# trade's own size and the guard's headroom are each named by the literal
|
|
# text around them.
|
|
capped = size_w < asked_w
|
|
return ArbitrageDecision(
|
|
action=CHARGE, power_w=-size_w,
|
|
reason=(f"{CHARGE}: this is the cheapest firm price period 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 {size_w:.0f} W from {soc:g} %"
|
|
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
|
|
f"{asked_w:.0f} W and this billed quarter can afford "
|
|
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
|
|
if capped else "")),
|
|
spread_eur_kwh=v.spread_eur_kwh,
|
|
required_eur_kwh=v.required_spread_eur_kwh,
|
|
price_now_eur_kwh=price)
|
|
|
|
|
|
def sized_from_meter(decision: ArbitrageDecision, headroom_w, *,
|
|
winner: str | None) -> bool:
|
|
"""SAFETY-47. Was the number a shadow run just recorded SIZED FROM THE
|
|
BILLED QUARTER - i.e. from a measurement of what the battery itself did?
|
|
|
|
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
|
`peakguard.charge_headroom_w()`, `discharge_permitted()` 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.
|
|
|
|
------------------------------------------------------------------------
|
|
THE DEFECT IT PREVENTS
|
|
|
|
`shadow.words()` and the action log carry "would have commanded N W". In
|
|
SHADOW MODE the battery is held at `shadow.WIRE_W`, so the billed quarter
|
|
the buy leg was sized against contains THE HOUSE AND NOTHING ELSE - while
|
|
a live run's meter rises as the trade charges. The same code, the same
|
|
hour, `peak_cap_w` 1500 under a 1200 W house: live commands -302.4 W and a
|
|
shadow run records -2000.0 W. 6.61x, on the status row, with no caveat.
|
|
This is the flag that puts the caveat there.
|
|
|
|
⚠️ IT IS TRUE WHENEVER A MEASURED HEADROOM ENTERED THE ARITHMETIC, NOT
|
|
ONLY WHEN IT BOUND. `_act()` sizes the buy leg as `min(asked, headroom)`,
|
|
and "the headroom did not bind" is ITSELF a measured fact about a quarter
|
|
the battery never entered. Keying this on `capped` - on whether the
|
|
shipped sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" - would answer
|
|
True at the START of the quarter, where a shadow run and a live run still
|
|
agree to the watt, and False at the END, where `(900/remaining)` has
|
|
multiplied the headroom past the ask and the recorded number is at its
|
|
most wrong. The quietest caveat exactly where it is needed loudest.
|
|
|
|
⚠️ THE BUY LEG ONLY. §9.1: "Discharge and export are never capped by the
|
|
peak guard; only charging and import are." A sell is sized from
|
|
`discharge_w`, which is the roster's own rating and not a measurement of
|
|
the quarter, so a sell's recorded watts are NOT subject to this and must
|
|
not carry a caveat that would then be false.
|
|
|
|
⚠️ AND THE ARBITER'S WINNER IS THE THIRD RUNG, NOT AN AFTERTHOUGHT. A trade
|
|
that was sized from the meter and then OUT-RANKED is not the number being
|
|
recorded - `Resolution.target_w` belongs to somebody else - and a caveat
|
|
filed against somebody else's number is a false sentence on the status row,
|
|
which is the defect class this whole card is about. It is in HERE and not
|
|
in `main.py` for the reason the two rungs above are: all three answer one
|
|
question, a check has to be able to reach each of them with the other two
|
|
clear (TEST-16), and a rung that lives at a call site is a rung no check
|
|
can drive.
|
|
|
|
`headroom_w` is whatever `peakguard.charge_headroom_w()` answered for the
|
|
cycle this decision was taken on. ⚠️ None IS "NOTHING MEASURED IT", and
|
|
`decide()` REFUSES the trade there (IDLE_HEADROOM_UNMEASURED), so a None
|
|
can only ever arrive here beside a non-trading decision - but it is tested
|
|
rather than assumed, because the caller is `main.py` and not this module.
|
|
|
|
`winner` is `Resolution.winner`: the source that actually set the watts.
|
|
"""
|
|
return (winner == SOURCE
|
|
and decision is not None and decision.action == CHARGE
|
|
and headroom_w is not None)
|
|
|
|
|
|
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)
|