Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8b8cb97f | ||
|
|
8e589fee3f | ||
|
|
bdd7f18a54 | ||
|
|
a30bde1ab1 |
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||||||
|
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||||||
|
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||||||
|
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
|
||||||
|
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
|
||||||
|
add-on that commands the battery against the meter on purpose.
|
||||||
|
|
||||||
|
**It consumes the four cards rather than re-deciding anything.** Whether a
|
||||||
|
trade is permitted at all, on which two hours, and against which widened
|
||||||
|
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
|
||||||
|
cannot be obtained without first stating whether the peak guard is holding. So
|
||||||
|
§9.1's "give peak shaving priority over arbitrage" is honoured
|
||||||
|
**structurally**: there is no code path to a tradeable spread that skips the
|
||||||
|
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
|
||||||
|
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
|
||||||
|
if that interlock were bypassed. Two independent mechanisms, neither
|
||||||
|
re-implemented here.
|
||||||
|
|
||||||
|
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
|
||||||
|
there is no telegram stream, so the peak guard is never evaluated. That third
|
||||||
|
state is carried as `None` and it **refuses**: charging at the rail creates the
|
||||||
|
billed quantity while nothing measures the quarter it lands in. `validate.py`
|
||||||
|
refuses that configuration at startup too, along with arbitrage armed on a site
|
||||||
|
with no price source at all, 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 has a 0.25 EUR/kWh
|
||||||
|
spread that clears any shipped threshold and a dearest hour that is still
|
||||||
|
negative. Buying in a negative hour stays allowed; being paid to charge is the
|
||||||
|
right trade.
|
||||||
|
|
||||||
|
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
|
||||||
|
may open an HTTPS connection, and the control tick is what feeds the ESP32
|
||||||
|
heartbeat - a supplier's TLS timeout there is a supplier able to push the
|
||||||
|
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
|
||||||
|
the tick reads an attribute and an age bound, and a plan whose refresh has
|
||||||
|
stopped answering retires rather than standing as if it were current.
|
||||||
|
|
||||||
|
**What is honestly not measured.** The acceptance criterion asked for
|
||||||
|
net-positivity across 12 months of *replayed* prices. This repository holds no
|
||||||
|
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
|
||||||
|
and is not restated**. What `test_arbitrage.py` section 7 does instead is
|
||||||
|
replay a synthetic year and assert the property that does not need real prices:
|
||||||
|
every executed round trip cleared its own widened requirement, on all of them,
|
||||||
|
across a year containing 522 hours priced below zero and zero discharges into
|
||||||
|
one. And 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 the
|
||||||
|
widening factors beside it, are one product decision and it is open.
|
||||||
|
|
||||||
**Provisional prices, and what may not rest on one (FCST-03).**
|
**Provisional prices, and what may not rest on one (FCST-03).**
|
||||||
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
|
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
|
||||||
the unknown hours use yesterday's profile and are flagged `provisional: true` —
|
the unknown hours use yesterday's profile and are flagged `provisional: true` —
|
||||||
|
|||||||
@@ -644,15 +644,16 @@ floor of 60 s that figure is 60 W and only the margin is left.
|
|||||||
|
|
||||||
### Energy price providers
|
### Energy price providers
|
||||||
|
|
||||||
> **No *fetched* price moves energy in 0.5.x.** This release ships the
|
> **A fetched price moves energy only when you switch arbitrage on.** This
|
||||||
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
|
> release ships the five provider plugins, the canonical all-in format they
|
||||||
> and the config that selects one. Turning a provider on fetches, validates and
|
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||||
> publishes prices and changes no charging decision; the arbitrage strategy that
|
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||||
> would use it is a later ticket.
|
> shipped default, turning a provider on still fetches, validates and publishes
|
||||||
|
> prices and changes no charging decision.
|
||||||
>
|
>
|
||||||
> The one exception is `static_profile` below. A static day/night tariff needs
|
> `static_profile` below needs no network at all, so its cheap window is
|
||||||
> no network, so its cheap window is computed on every cycle and does reach the
|
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||||
> maintenance cycle's grid top-up.
|
> whether or not arbitrage is armed.
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -804,6 +805,50 @@ to, and the site simply has no tariff until the feed returns. It is not required
|
|||||||
— the add-on warns rather than refusing — but filling it in is what makes that
|
— the add-on warns rather than refusing — but filling it in is what makes that
|
||||||
fallback work.
|
fallback work.
|
||||||
|
|
||||||
|
### Price arbitrage (`arbitrage_enabled`)
|
||||||
|
|
||||||
|
§10.1 priority 3: charge in the cheapest published hour, discharge in the
|
||||||
|
dearest, **and only when the spread covers what the round trip costs.** Off by
|
||||||
|
default — this is the first strategy in the add-on that commands the battery
|
||||||
|
*against* the meter on purpose, so it is armed deliberately or not at all.
|
||||||
|
|
||||||
|
| option | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `arbitrage_enabled` | `false` by default. Arms the strategy |
|
||||||
|
| `cycle_cost_threshold_eur` | What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded |
|
||||||
|
| `arbitrage_soc_floor` | The reserve arbitrage may not spend, % |
|
||||||
|
| `arbitrage_soc_ceiling` | The headroom it may not overfill, % |
|
||||||
|
|
||||||
|
**What it needs, and what the add-on refuses.** Arbitrage requires a price
|
||||||
|
source (`price_provider` or `static_profile`) and a P1 meter (`meter_source`).
|
||||||
|
Without a price source there is nothing to tell cheap from expensive; without
|
||||||
|
P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above
|
||||||
|
arbitrage — a charge that nothing measures is exactly what that guard exists to
|
||||||
|
prevent. Both combinations are **refused at startup** rather than left looking
|
||||||
|
armed.
|
||||||
|
|
||||||
|
**Peak shaving always wins.** The strategy cannot obtain a spread at all
|
||||||
|
without first answering whether the peak guard is holding, and even if it
|
||||||
|
could, the guard's floor is filed at a higher arbiter priority and clamps an
|
||||||
|
arbitrage charge. Discharging is never capped by it — only offtake is billed.
|
||||||
|
|
||||||
|
**It never exports into a negative price** (§17). A negative hour is the best
|
||||||
|
possible hour to *charge* in and that is still allowed; selling into one gives
|
||||||
|
energy away and pays for the privilege.
|
||||||
|
|
||||||
|
**At the shipped threshold a static day/night site does not trade.** The
|
||||||
|
realistic Belgian day/night spread is about 0.08 EUR/kWh and the default
|
||||||
|
threshold is 0.10, so the honest answer on such a site is "no opportunity", and
|
||||||
|
the status page says so with the numbers. Whether 0.10 is the right default is
|
||||||
|
a product decision, not a bug: lower it only if you know what a cycle costs
|
||||||
|
your pack.
|
||||||
|
|
||||||
|
**The prices are fetched in the background**, never on the control tick — a
|
||||||
|
supplier's TLS timeout on that path would be a supplier able to push the add-on
|
||||||
|
towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||||||
|
retires itself if the refresh stops answering, at which point arbitrage stops
|
||||||
|
with it.
|
||||||
|
|
||||||
### Site
|
### Site
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
|||||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||||
|
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||||
|
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||||
|
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
|
||||||
|
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
|
||||||
|
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
|
||||||
|
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
|
||||||
|
# because trading against the meter is the entire point - the grid-following
|
||||||
|
# loop would otherwise cancel every charge the moment it created import.
|
||||||
|
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
||||||
P_LOOP = 10 # ordinary grid-following
|
P_LOOP = 10 # ordinary grid-following
|
||||||
|
|
||||||
SET = "set"
|
SET = "set"
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
"""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)
|
||||||
@@ -28,10 +28,11 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from . import arbitrage
|
||||||
from .arbiter import (
|
from .arbiter import (
|
||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||||
)
|
)
|
||||||
@@ -48,7 +49,8 @@ from .maintenance import IDLE, MaintConfig, Maintenance
|
|||||||
from .mqtt import MqttPublisher
|
from .mqtt import MqttPublisher
|
||||||
from . import peakguard
|
from . import peakguard
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
from .static_tariff import static_status
|
from .price import Uncertainty, price_option
|
||||||
|
from .static_tariff import resolve_tariff, static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
from .tariff import PeakStore, PeakTracker
|
||||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||||
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
||||||
@@ -57,6 +59,34 @@ from . import web
|
|||||||
OPTIONS_PATH = "/data/options.json"
|
OPTIONS_PATH = "/data/options.json"
|
||||||
_LOG = logging.getLogger("goodwe")
|
_LOG = logging.getLogger("goodwe")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
|
||||||
|
#
|
||||||
|
# `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick
|
||||||
|
# is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat
|
||||||
|
# stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able
|
||||||
|
# to push this add-on into the failsafe. Every module in the price stack says
|
||||||
|
# so in its own words (static_tariff.static_plan, resolve_tariff, main's
|
||||||
|
# read_inputs) and this is the task those notes were waiting for.
|
||||||
|
#
|
||||||
|
# The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an
|
||||||
|
# attribute this task assigns, and nothing else.
|
||||||
|
#
|
||||||
|
# ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed
|
||||||
|
# publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that
|
||||||
|
# moment; 30 minutes is short enough that the switch is picked up inside the
|
||||||
|
# hour it happens and long enough to be a polite 48 fetches a day. Nothing in
|
||||||
|
# the control law depends on it.
|
||||||
|
TARIFF_REFRESH_S = 1800.0
|
||||||
|
# ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still
|
||||||
|
# stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which
|
||||||
|
# is provisional and refuses arbitrage on its own. This bound covers the other
|
||||||
|
# case: the refresh task itself dying, leaving a plan that was true hours ago
|
||||||
|
# standing as if it were current. Three missed refreshes is the threshold, for
|
||||||
|
# `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is.
|
||||||
|
# A failed read is not an observation, and neither is a stale one.
|
||||||
|
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||||
|
|
||||||
|
|
||||||
def load_options() -> dict:
|
def load_options() -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -232,6 +262,16 @@ class Controller:
|
|||||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||||
# no" is an evidenced answer rather than a bare no.
|
# no" is an evidenced answer rather than a bare no.
|
||||||
self.tariff_words = "no static profile"
|
self.tariff_words = "no static profile"
|
||||||
|
|
||||||
|
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
|
||||||
|
# cycle() reads these two attributes and never awaits a fetch - see
|
||||||
|
# TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is
|
||||||
|
# not "no prices" and certainly not "free": arbitrage.decide() reports
|
||||||
|
# it as its own reason and does nothing.
|
||||||
|
self.tariff_plan = None
|
||||||
|
self.tariff_at = None
|
||||||
|
self.arb = None
|
||||||
|
self._arb_action = None
|
||||||
self.last_write = None
|
self.last_write = None
|
||||||
self.last_write_at = None
|
self.last_write_at = None
|
||||||
self.last_write_ok = None
|
self.last_write_ok = None
|
||||||
@@ -514,6 +554,64 @@ class Controller:
|
|||||||
if self.peak_guard is not None:
|
if self.peak_guard is not None:
|
||||||
self.log_event(self.peak_guard.explain())
|
self.log_event(self.peak_guard.explain())
|
||||||
|
|
||||||
|
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
|
||||||
|
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
|
||||||
|
# argument. What is decided HERE and nowhere else is what the strategy
|
||||||
|
# is allowed to see:
|
||||||
|
#
|
||||||
|
# ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION.
|
||||||
|
# `engaged_now` is this cycle's peak verdict, computed ~20 lines up.
|
||||||
|
# Evaluated before it, this would hand the strategy the PREVIOUS cycle's
|
||||||
|
# answer - one cycle of lag on the interlock that implements §9.1's
|
||||||
|
# "peak shaving outranks arbitrage", inside a 900 s quarter.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not
|
||||||
|
# CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on
|
||||||
|
# it, because charging at max_w creates the billed quantity while
|
||||||
|
# nothing is measuring the quarter it lands in. `False` here would be
|
||||||
|
# this line asserting a measurement that was never taken - the defect
|
||||||
|
# `PeakReading` and `quarter_ceiling_w()` both exist to remove.
|
||||||
|
#
|
||||||
|
# ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT
|
||||||
|
# HERE, and that is not tidiness. It is the same lesson
|
||||||
|
# `shaping_suspended()` records: inline, this expression was reachable
|
||||||
|
# by no check, and mutating it back to a bare `engaged_now` left every
|
||||||
|
# suite in this repository green while a `meter_source: off` install
|
||||||
|
# traded blind. Read guard_state() before touching this line.
|
||||||
|
#
|
||||||
|
# ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read
|
||||||
|
# and an age comparison; the HTTPS call lives in run_tariff_refresh().
|
||||||
|
# See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a
|
||||||
|
# supplier able to push this add-on towards layer 1's 30 s veto.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's
|
||||||
|
# no-weather row widens the threshold, and this add-on carries no
|
||||||
|
# weather forecast at all - §8.1's PV forecast is not built. Passing
|
||||||
|
# `frozenset()` would be main.py asserting a forecast is healthy when
|
||||||
|
# there is not one, which is exactly what static_tariff's `uncertainty`
|
||||||
|
# argument has no default in order to prevent.
|
||||||
|
self.arb = None
|
||||||
|
if arbitrage.enabled(self.o):
|
||||||
|
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||||
|
self.arb = arbitrage.decide(
|
||||||
|
self.fresh_tariff_plan(now), now,
|
||||||
|
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||||
|
soc=self.soc,
|
||||||
|
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||||
|
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||||
|
charge_w=max_w, discharge_w=max_w,
|
||||||
|
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||||
|
arb_claim = arbitrage.claim(self.arb)
|
||||||
|
if arb_claim is not None:
|
||||||
|
claims.append(arb_claim)
|
||||||
|
# Edge-logged, for the peak guard's reason: the action log holds 40
|
||||||
|
# lines and a trade lasts a whole hour, so a line per cycle would
|
||||||
|
# flush the context that explains it. The transition is what an
|
||||||
|
# operator needs - "it started buying at 03:00", not 240 copies.
|
||||||
|
if self.arb.action != self._arb_action:
|
||||||
|
self._arb_action = self.arb.action
|
||||||
|
self.log_event(self.arb.explain())
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------
|
# --- maintenance -----------------------------------------------------
|
||||||
if result.owns_setpoint:
|
if result.owns_setpoint:
|
||||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||||
@@ -866,6 +964,57 @@ class Controller:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# -- tasks ---------------------------------------------------------------
|
# -- tasks ---------------------------------------------------------------
|
||||||
|
# -- OPT-04: the background tariff refresh -------------------------------
|
||||||
|
def fresh_tariff_plan(self, now):
|
||||||
|
"""The stored plan if it is still current, else None. NEVER a stale one.
|
||||||
|
|
||||||
|
⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
||||||
|
`loop_is_following()`'s reason: a check has to be able to assert on the
|
||||||
|
shipped expression rather than on a transcription that drifts. Both
|
||||||
|
terms are required - a plan with no timestamp is one nothing stored, and
|
||||||
|
a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task
|
||||||
|
that has stopped answering.
|
||||||
|
"""
|
||||||
|
if self.tariff_plan is None or self.tariff_at is None:
|
||||||
|
return None
|
||||||
|
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
||||||
|
return None
|
||||||
|
return self.tariff_plan
|
||||||
|
|
||||||
|
async def run_tariff_refresh(self) -> None:
|
||||||
|
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
||||||
|
|
||||||
|
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason:
|
||||||
|
a supplier's outage, a certificate expiry, a malformed feed - all of it
|
||||||
|
is pricing, and the battery does not care. What it must never do is
|
||||||
|
raise out of here and take the task down, because then the plan freezes
|
||||||
|
at its last value and looks current forever. That is what
|
||||||
|
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
|
||||||
|
half that keeps the task alive so the bound rarely matters.
|
||||||
|
|
||||||
|
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
|
||||||
|
a §11.4 answer for a dead feed - degrade to the static profile, mark it
|
||||||
|
provisional, arbitrage refuses - and that answer is better than the
|
||||||
|
absence of one. Zeroing the plan here would replace a coarse tariff with
|
||||||
|
no tariff, which is the opposite of what §11.4 asks for.
|
||||||
|
"""
|
||||||
|
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||||
|
while not self.stopping:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
plan = await resolve_tariff(
|
||||||
|
self.o, now, now + timedelta(hours=horizon), now=now)
|
||||||
|
self.tariff_plan, self.tariff_at = plan, now
|
||||||
|
_LOG.info("tariff refreshed: %s", plan.words)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOG.warning(
|
||||||
|
"tariff refresh failed (%s); keeping the plan resolved at "
|
||||||
|
"%s. It retires on its own after %.0f s and arbitrage "
|
||||||
|
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||||
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(self.stop_event.wait(),
|
||||||
|
timeout=TARIFF_REFRESH_S)
|
||||||
|
|
||||||
async def run_control(self) -> None:
|
async def run_control(self) -> None:
|
||||||
entity = self.o.get("setpoint_entity", "")
|
entity = self.o.get("setpoint_entity", "")
|
||||||
if entity:
|
if entity:
|
||||||
@@ -1243,6 +1392,11 @@ class Controller:
|
|||||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("Tariff", self.tariff_words),
|
||||||
|
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||||
|
# every refusal it can make already carries a reason a human can
|
||||||
|
# act on, and "Arbitrage: no" would throw all of them away.
|
||||||
|
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
|
||||||
|
else self.arb.explain()),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||||
@@ -1370,6 +1524,19 @@ async def amain() -> None:
|
|||||||
|
|
||||||
tasks = [asyncio.create_task(controller.run_control())]
|
tasks = [asyncio.create_task(controller.run_control())]
|
||||||
|
|
||||||
|
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
|
||||||
|
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
|
||||||
|
# HTTPS connection to a supplier, and the control tick is what feeds the
|
||||||
|
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||||
|
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||||
|
#
|
||||||
|
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||||
|
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||||
|
# the tick, which is pure - so on a default install this task would be
|
||||||
|
# network traffic in aid of an attribute nobody reads.
|
||||||
|
if arbitrage.enabled(opts):
|
||||||
|
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||||
|
|
||||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||||
# off the control loop: telegrams arrive every ~5 s and the loop would
|
# off the control loop: telegrams arrive every ~5 s and the loop would
|
||||||
# decimate them, so the 15-minute average - the capacity-tariff billing
|
# decimate them, so the 15-minute average - the capacity-tariff billing
|
||||||
|
|||||||
@@ -607,6 +607,26 @@ PRICE_OPTION_DEFAULTS = {
|
|||||||
# test_price.py section 1. A second defaults dict in the new module would be
|
# test_price.py section 1. A second defaults dict in the new module would be
|
||||||
# a fourth place option defaults live, which is exactly the open ticket.
|
# a fourth place option defaults live, which is exactly the open ticket.
|
||||||
"static_profile": "",
|
"static_profile": "",
|
||||||
|
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
|
||||||
|
#
|
||||||
|
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
|
||||||
|
# FCST-03's note above `WIDEN_FACTOR` names this as the place they would
|
||||||
|
# land. `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are the reserve
|
||||||
|
# arbitrage may not spend and the headroom it may not overfill; they are
|
||||||
|
# NOT the maintenance SoC keys, which describe a balancing cycle rather
|
||||||
|
# than a trading reserve and would silently couple two unrelated policies.
|
||||||
|
#
|
||||||
|
# ⚠️ AND `cycle_cost_threshold_eur` IS 0.10 BECAUSE §10.1's NAME FOR IT SAYS
|
||||||
|
# SO, NOT BECAUSE IT MAKES ANYTHING TRADE. On realistic Belgian day/night
|
||||||
|
# numbers the static spread is ~0.08 EUR/kWh, so a static-tariff site does
|
||||||
|
# NOT trade at this default. That is a reported result (test_arbitrage.py
|
||||||
|
# section 7), and whether 0.10 - or FCST-03's 2.5/1.5 - is right is one
|
||||||
|
# product decision that belongs to the owner. No number here was chosen to
|
||||||
|
# make arbitrage happen.
|
||||||
|
"arbitrage_enabled": False,
|
||||||
|
"cycle_cost_threshold_eur": 0.10,
|
||||||
|
"arbitrage_soc_floor": 20,
|
||||||
|
"arbitrage_soc_ceiling": 95,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
|
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
|
||||||
|
|||||||
@@ -155,8 +155,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .arbitrage import enabled as arbitrage_on
|
||||||
from .p1 import is_enabled, validate_meter_options
|
from .p1 import is_enabled, validate_meter_options
|
||||||
from .price import PROVIDERS
|
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.config")
|
_LOG = logging.getLogger("goodwe.config")
|
||||||
@@ -453,14 +454,21 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
# arbitrage verdict, so the seam is no longer inert.
|
# arbitrage verdict, so the seam is no longer inert.
|
||||||
# * main.py reads the STATIC side of that module every cycle
|
# * main.py reads the STATIC side of that module every cycle
|
||||||
# (static_status()), which makes static_profile a real writer of `cheap`.
|
# (static_status()), which makes static_profile a real writer of `cheap`.
|
||||||
# * main.py does NOT yet call resolve_tariff() on the control tick, and
|
# * main.py does NOT call resolve_tariff() on the control tick, and that
|
||||||
# that is deliberate: a provider fetch is network I/O and the tick is
|
# is still deliberate: a provider fetch is network I/O and the tick is
|
||||||
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
|
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
|
||||||
# no energy decision on its own; a configured STATIC PROFILE does.
|
# instead (Controller.run_tariff_refresh), and the tick reads the plan
|
||||||
|
# that task last stored - an attribute read, with nothing to block on.
|
||||||
|
# * so a configured PROVIDER now DOES shape an energy decision, but only
|
||||||
|
# through app/arbitrage.py and only while `arbitrage_enabled` is on.
|
||||||
|
# With it off - the shipped configuration - the series is still fetched,
|
||||||
|
# validated and published, and still moves nothing.
|
||||||
#
|
#
|
||||||
# Hence two refusals below rather than one, and neither claims more than
|
# Hence three refusals below rather than two, and none of them claims more
|
||||||
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
|
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
|
||||||
# first message's middle clause is what changes - not the rule.
|
# CHANGED, exactly as the note it replaced predicted - the RULE is
|
||||||
|
# untouched, because the reason it exists (main.py reads the Home Assistant
|
||||||
|
# pair FIRST and the other source is silent) is unchanged.
|
||||||
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
||||||
if str(opts.get(k, "") or "").strip()]
|
if str(opts.get(k, "") or "").strip()]
|
||||||
if pid != "off" and ha_price:
|
if pid != "off" and ha_price:
|
||||||
@@ -469,10 +477,12 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
||||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
||||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
||||||
"reads. The provider series IS consumed now - app/static_tariff.py "
|
"reads, and read_inputs() never looks at the other source once "
|
||||||
"builds a tariff plan and an arbitrage verdict from it - but it is "
|
"they are set. The provider series is consumed - "
|
||||||
"not what main.py acts on, so the supplier you configured would "
|
"app/static_tariff.py builds a tariff plan and an arbitrage "
|
||||||
"shape no energy decision while the status page named it. Clear "
|
"verdict, and app/arbitrage.py trades on it off a background "
|
||||||
|
"refresh - but the `cheap` flag those entities own is a separate "
|
||||||
|
"decision they would silently keep. Clear "
|
||||||
"price_now_entity and price_avg_entity to use the "
|
"price_now_entity and price_avg_entity to use the "
|
||||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||||
"Home Assistant sensors.")
|
"Home Assistant sensors.")
|
||||||
@@ -528,6 +538,67 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||||
"but filling it in is what makes that row work.", pid)
|
"but filling it in is what makes that row work.", pid)
|
||||||
|
|
||||||
|
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||||
|
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||||
|
# fire is indistinguishable from one that fires and finds no opportunity -
|
||||||
|
# which is the §14 warning box's case exactly. None of these can break an
|
||||||
|
# existing install: the option is new and ships `false`.
|
||||||
|
# ⚠️ CHECKED WHETHER OR NOT ARBITRAGE IS ARMED. A threshold that is not a
|
||||||
|
# non-negative number is a configuration error the moment it is typed, and
|
||||||
|
# deferring the complaint until somebody turns the strategy on would report
|
||||||
|
# it at the worst possible moment - the one where energy starts moving.
|
||||||
|
thresh = _number(opts, "cycle_cost_threshold_eur",
|
||||||
|
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
||||||
|
if thresh is None or thresh < 0:
|
||||||
|
problems.append(
|
||||||
|
f"cycle_cost_threshold_eur is "
|
||||||
|
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
|
||||||
|
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
|
||||||
|
f"(§10.1). A negative threshold is one that every spread clears, "
|
||||||
|
f"including a spread of nothing, which is arbitrage with the brake "
|
||||||
|
f"removed rather than arbitrage tuned aggressively.")
|
||||||
|
|
||||||
|
if arbitrage_on(opts):
|
||||||
|
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||||
|
problems.append(
|
||||||
|
"arbitrage_enabled is on but price_provider is 'off' and "
|
||||||
|
"static_profile is empty, so this site has NO PRICES AT ALL. "
|
||||||
|
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
|
||||||
|
"there is nothing here to tell cheap from expensive, so the "
|
||||||
|
"strategy would refuse on every cycle forever while the "
|
||||||
|
"configuration said it was running. Configure a price_provider "
|
||||||
|
"or a static_profile, or turn arbitrage_enabled off.")
|
||||||
|
if not is_enabled(opts):
|
||||||
|
problems.append(
|
||||||
|
"arbitrage_enabled is on but meter_source is 'off', so there "
|
||||||
|
"is no P1 telegram stream, the capacity-tariff peak guard "
|
||||||
|
"never runs, and nothing measures the 15-minute average this "
|
||||||
|
"strategy's charging would land in. §9.1 gives peak shaving "
|
||||||
|
"priority over arbitrage; an unmeasured peak cannot be shaved, "
|
||||||
|
"so arbitrage would be deliberately creating the billed "
|
||||||
|
"quantity with the one guard that limits it switched off. "
|
||||||
|
"app/arbitrage.py refuses to trade in that state anyway - this "
|
||||||
|
"refuses the configuration that asks it to. Set meter_source, "
|
||||||
|
"or turn arbitrage_enabled off.")
|
||||||
|
floor = _number(opts, "arbitrage_soc_floor",
|
||||||
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
||||||
|
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
||||||
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
||||||
|
if floor is None or ceiling is None:
|
||||||
|
problems.append(
|
||||||
|
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
|
||||||
|
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
|
||||||
|
f"must both be numbers - they are percentages of pack charge.")
|
||||||
|
elif floor >= ceiling:
|
||||||
|
problems.append(
|
||||||
|
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
|
||||||
|
f"is {ceiling:g} %, so there is no band left between them: "
|
||||||
|
f"every state of charge is either at or under the floor (no "
|
||||||
|
f"discharge) or at or over the ceiling (no charge), and the "
|
||||||
|
f"strategy can never act. The floor is the reserve it may not "
|
||||||
|
f"spend and the ceiling is the headroom it may not overfill, "
|
||||||
|
f"so the floor must be the SMALLER number.")
|
||||||
|
|
||||||
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
||||||
|
|||||||
@@ -240,6 +240,38 @@ options:
|
|||||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||||
export_price_eur_kwh: 0.04
|
export_price_eur_kwh: 0.04
|
||||||
|
|
||||||
|
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
||||||
|
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
||||||
|
# when the spread covers what the round trip costs. Off in the shipped
|
||||||
|
# configuration: this is the first strategy in the add-on that moves energy
|
||||||
|
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||||
|
#
|
||||||
|
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||||
|
# combinations that cannot work rather than letting them look armed. With
|
||||||
|
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||||
|
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
|
||||||
|
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
|
||||||
|
# exact failure that guard exists to prevent.
|
||||||
|
#
|
||||||
|
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
|
||||||
|
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
|
||||||
|
# below is 0.10, so the verdict is "the firm spread does not cover this
|
||||||
|
# cycle's cost" and nothing happens - correctly. That is a product decision
|
||||||
|
# (this number, and app/price.py's widening factors) and it is open with the
|
||||||
|
# owner; the code is correct at any value and no number was picked to make a
|
||||||
|
# trade appear.
|
||||||
|
arbitrage_enabled: false
|
||||||
|
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
||||||
|
# - the wear on the pack plus the conversion losses. A spread below this is
|
||||||
|
# a cycle that loses money and pack life, so it is not taken.
|
||||||
|
cycle_cost_threshold_eur: 0.10
|
||||||
|
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
||||||
|
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
||||||
|
# balancing cycle, and pointing a trading strategy at them would couple two
|
||||||
|
# unrelated policies through one number.
|
||||||
|
arbitrage_soc_floor: 20
|
||||||
|
arbitrage_soc_ceiling: 95
|
||||||
|
|
||||||
# --- site ------------------------------------------------------------------
|
# --- site ------------------------------------------------------------------
|
||||||
estop_fitted: false
|
estop_fitted: false
|
||||||
log_level: info
|
log_level: info
|
||||||
@@ -337,5 +369,15 @@ schema:
|
|||||||
# bottoms out at 0 would make that site inexpressible.
|
# bottoms out at 0 would make that site inexpressible.
|
||||||
export_price_eur_kwh: float(-1,1)
|
export_price_eur_kwh: float(-1,1)
|
||||||
|
|
||||||
|
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||||
|
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||||
|
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||||
|
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||||
|
# handed any dict at all.
|
||||||
|
arbitrage_enabled: bool
|
||||||
|
cycle_cost_threshold_eur: float(0,1)
|
||||||
|
arbitrage_soc_floor: int(5,95)
|
||||||
|
arbitrage_soc_ceiling: int(10,100)
|
||||||
|
|
||||||
estop_fitted: bool
|
estop_fitted: bool
|
||||||
log_level: list(trace|debug|info|warning|error)
|
log_level: list(trace|debug|info|warning|error)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -212,6 +212,14 @@ def shipped(key):
|
|||||||
if in_schema or not line.startswith(f" {key}:"):
|
if in_schema or not line.startswith(f" {key}:"):
|
||||||
continue
|
continue
|
||||||
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
|
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
|
||||||
|
# OPT-04 added the first BOOLEAN price option, and without this the
|
||||||
|
# mirror check would compare the string "false" against `False` and go
|
||||||
|
# red for a file that is perfectly in step. Parsing a bool as a bool is
|
||||||
|
# what the yaml module this image does not carry would have done; it
|
||||||
|
# widens nothing, because a key whose shipped value disagrees with the
|
||||||
|
# mirror still drifts.
|
||||||
|
if raw in ("true", "false"):
|
||||||
|
return raw == "true"
|
||||||
try:
|
try:
|
||||||
return int(raw)
|
return int(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from app.price_forecast import (
|
|||||||
MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours,
|
MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours,
|
||||||
fill_from_yesterday, price_permits, begin_phase,
|
fill_from_yesterday, price_permits, begin_phase,
|
||||||
)
|
)
|
||||||
|
from app.arbitrage import ArbitrageDecision
|
||||||
from app.static_tariff import Arbitrage, TariffPlan
|
from app.static_tariff import Arbitrage, TariffPlan
|
||||||
|
|
||||||
UTC = timezone.utc
|
UTC = timezone.utc
|
||||||
@@ -582,7 +583,13 @@ _ctors = {"Gate.permit": Gate.permit, "Gate.block": Gate.block,
|
|||||||
"Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse,
|
"Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse,
|
||||||
"Arbitrage.match": Arbitrage.match,
|
"Arbitrage.match": Arbitrage.match,
|
||||||
"_Allowed.__init__": _st._Allowed.__init__,
|
"_Allowed.__init__": _st._Allowed.__init__,
|
||||||
"_Refused.__init__": _st._Refused.__init__}
|
"_Refused.__init__": _st._Refused.__init__,
|
||||||
|
# ⚠️ OPT-04's outcome type is scanned HERE, in the same dict, rather
|
||||||
|
# than by a hand-written case in test_arbitrage.py - which is TEST-14's
|
||||||
|
# actual instruction. A new outcome type extends the SCAN; a
|
||||||
|
# hand-written case is the thing that gets forgotten, and the whole
|
||||||
|
# reason this dict exists instead of five separate checks.
|
||||||
|
"ArbitrageDecision.__init__": ArbitrageDecision.__init__}
|
||||||
_with_defaults = derived(lambda: {n: d for n, d in
|
_with_defaults = derived(lambda: {n: d for n, d in
|
||||||
((n, _defaulted(f)) for n, f in _ctors.items())
|
((n, _defaulted(f)) for n, f in _ctors.items())
|
||||||
if d})
|
if d})
|
||||||
@@ -835,13 +842,28 @@ print("7. CFG-03 and validate.py rule 7 - what this ticket did NOT widen")
|
|||||||
_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS))
|
_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS))
|
||||||
_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
||||||
encoding="utf-8").read())
|
encoding="utf-8").read())
|
||||||
check(f"FCST-03 added ZERO option keys: the in-code mirror still holds exactly "
|
# ⚠️ THIS LIST IS A DRIFT PIN, NOT A BAN ON EVER ADDING AN OPTION. FCST-03 added
|
||||||
f"TARIFF-02's and TARIFF-03's {_defaults}",
|
# none, and said so here. OPT-04 added FOUR - and added them to this ONE dict
|
||||||
|
# and to config.yaml, which is precisely the behaviour CFG-03 asks for: the
|
||||||
|
# alternative is a fourth freehand copy in main.py's `o.get(k, X)` fallbacks.
|
||||||
|
# The pin is what makes such an addition a decision somebody signs rather than a
|
||||||
|
# thing that appears; whoever adds the fifth comes here and says which card and
|
||||||
|
# why, as this line does.
|
||||||
|
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||||
|
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
|
||||||
|
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
|
||||||
|
f"keys - FCST-03 still added none of its own {_defaults}",
|
||||||
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
|
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
|
||||||
"price_taxes_eur_kwh", "export_price_eur_kwh",
|
"price_taxes_eur_kwh", "export_price_eur_kwh",
|
||||||
"price_csv_path", "price_feed_url",
|
"price_csv_path", "price_feed_url",
|
||||||
"price_horizon_h", "price_timeout_s",
|
"price_horizon_h", "price_timeout_s",
|
||||||
"static_profile"]))
|
"static_profile"] + _OPT04_KEYS))
|
||||||
|
# ...and the four are genuinely NEW, not a rename of something that was already
|
||||||
|
# there, or the line above would pass while the mirror silently lost a key.
|
||||||
|
check("...and OPT-04's four keys are additions rather than replacements: the "
|
||||||
|
"nine TARIFF-02/03 keys are all still present",
|
||||||
|
isinstance(_defaults, list)
|
||||||
|
and len(set(_defaults) - set(_OPT04_KEYS)) == 9)
|
||||||
check("...and every one of them is still in config.yaml, which stays the "
|
check("...and every one of them is still in config.yaml, which stays the "
|
||||||
"source of truth",
|
"source of truth",
|
||||||
isinstance(_cfg, str)
|
isinstance(_cfg, str)
|
||||||
|
|||||||
Reference in New Issue
Block a user