checks / unit (pull_request) Successful in 3m45s
specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.
WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.
THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).
Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.
WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:
pre_resolve() - one inverter's own claims -> that unit's interval. Clauses
1 and 2 transposed: the unit's own winning SET gates its
own limits. The SET's value travels BESIDE the interval,
not inside it - folding a -2500 W drain into
[-2500, -2500] would turn "clamped to max_w" into
"contradicts max_w", a different and wrong answer.
site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
allocate() - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
builds it. At N<=1 it passes the target through (§6.2's
identity). At N>=2 it DECLINES - no shares,
allocated=False - and main.py refuses to write rather than
broadcast, because the obvious placeholder (hand the target
to each) is the fault ARCH-01 was filed about and no guard
we have can tell it apart from the real thing.
THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.
AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.
EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
* Claim's dataclass default emptied -> RED (test_arbiter)
...and it survived until the check was added: the two helpers pass their
own default down, so the FIELD's default never ran. Now checked directly.
* `peak` reclassified SCOPE_UNIT -> RED (arbiter x4, peakguard x2)
* `maintenance` reclassified SCOPE_SITE -> RED (arbiter x4, peakguard)
* the N=1 identity dropped in actuator_for() -> RED (arbiter, peakguard
incl. the maintenance/peak suppression checks, freeze) - which is the
measurement that says splitting at N=1 would be a behaviour change
* resolve() stops partitioning -> RED (arbiter x4)
* allocate() broadcasts at N>=2 -> RED (arbiter x2)
* stage 1 drops clause-2 gating -> RED (arbiter)
* a call site routes `device` through `loop` -> RED (peakguard 10g)
* SAFETY-25 Option B built as a ninth source -> RED (peakguard 10f, both
halves, plus 10g) - the tripwire still bites
SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.
test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.
No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
434 lines
22 KiB
Python
434 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"
|
|
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
|
|
# ⚠️ 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)
|