Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2820e8b9b | ||
|
|
a1d07030f8 | ||
|
|
5dfe9720c5 | ||
|
|
242cf434f4 |
@@ -1,33 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.1
|
||||
|
||||
**A patch bump: two fixes for battery modes, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.0 also
|
||||
start 0.19.1. The changes below apply with one inverter or two.
|
||||
|
||||
**Battery modes now see a day/night or fixed tariff.** With battery modes on, a day/night or fixed tariff and no price service, the batteries never got the prices: "Cheap hours" never charged from the grid at the cheap rate, and the Batteries screen said "No prices". They get them now, with no price service and no internet call added.
|
||||
|
||||
**A solar forecast now counts for the price gap.** With battery modes on and a solar forecast read, a price mode needs a gap of at least *Battery cycle cost*, not 1.5 times that. Without a forecast it is still 1.5 times. A day/night gap of 0.08 now works at a cycle cost of 0.07 or less.
|
||||
|
||||
Contents of this release, in merge order since 0.19.0 (`793a676`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `TARIFF-24` | With battery modes on, the add-on now also resolves the tariff when no price service and no price trading are set, so a day/night or fixed tariff gives the batteries their price runs ([[TARIFF-24]]). With battery modes on and a solar forecast read, the price gap a mode needs is *Battery cycle cost* (widened only for a real-time feed), not 1.5 times that; Home and the Batteries screen use the same rule ([[AUDIT-02]] F1). |
|
||||
|
||||
### Known issues
|
||||
|
||||
The list under `## 0.19.0` below still applies.
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.0
|
||||
|
||||
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# SAFETY-123 - the day plan's hour loop steps in UTC
|
||||
|
||||
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
|
||||
|
||||
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
|
||||
the local wall clock and converting back. Where the zone has no tzdata
|
||||
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
|
||||
the missing 02:00 of the spring change reads as summer time, which is
|
||||
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
|
||||
grew until the process died. Control and the web page share one event loop,
|
||||
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
|
||||
looks at a battery's mode, so a site with all batteries on Solar first reaches
|
||||
it as well, if the prices give a plan.
|
||||
|
||||
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
|
||||
right answer, so a running add-on is not known to be hit. The fix is for any
|
||||
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
|
||||
|
||||
**Decision.** Each step adds the time left in the local hour to the UTC
|
||||
instant. It is at least a microsecond, so `t` always advances and the loop
|
||||
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
|
||||
and a day of 25 hours gives 25, each at most an hour, none across a local
|
||||
hour. On ordinary days the slices are the old ones.
|
||||
|
||||
**One change on the autumn day.** The old function (with tzdata) made one
|
||||
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
|
||||
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
|
||||
hour could tell the difference. There is none.
|
||||
|
||||
**Other loops.** The other code that walks local hours or days steps in UTC
|
||||
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
|
||||
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
|
||||
fixed bound. None can stall. One known inexactness stays, outside this fix:
|
||||
`solarfc` spreads a state-only forecast over hours since local midnight, so on
|
||||
the two change days its sun curve is an hour off the clock.
|
||||
@@ -1,64 +0,0 @@
|
||||
# TARIFF-24 - battery modes need the tariff task
|
||||
|
||||
**Status: FIXED in 0.19.1 (hotfix from 0.19.0, owner go 2026-10-28).**
|
||||
Follows TARIFF-10 (fetching is not trading). Tests: `test_tariff24.py`.
|
||||
|
||||
**The defect.** The control cycle gets prices only from
|
||||
`Controller.tariff_plan`, which only `run_tariff_refresh()` stores. `amain()`
|
||||
starts that task when `tariff_refresh_wanted(opts)`, which was
|
||||
`provider_configured(opts) or arbitrage.enabled(opts)`. Battery modes read the
|
||||
plan but were not a term. With battery modes on, a day/night or fixed
|
||||
`static_profile`, `price_provider: off` and `arbitrage_enabled: false` (the
|
||||
site trade switch is not read with modes on) no task ran. Then
|
||||
`fresh_tariff_plan()` was None, `_price_runs()` was None and `mode_runs` was
|
||||
None, so Leftover solar and Cheap hours never charged from the grid. The
|
||||
Batteries screen said "No prices, so it follows the house" while Home priced
|
||||
the day from `static_plan()` itself (`homeview.build_plan()`).
|
||||
|
||||
**The fix** (`main.py`, `tariff_refresh_wanted()`). `packmode.enabled(opts)`
|
||||
is a third term. The gate stays a widening: nothing that started the task
|
||||
before stops. No HTTPS call is added: with `price_provider: off`,
|
||||
`resolve_tariff()` is the pure `static_plan()`.
|
||||
|
||||
**Not changed.** Modes off, arbitrage off, no feed still starts no task. The
|
||||
control law, the price runs and every message are untouched.
|
||||
|
||||
**One rule, not two.** Gating on "modes on and a static profile" was
|
||||
considered. It saves one arithmetic pass every 30 min on a site with modes on
|
||||
and no prices at all, and it adds a second condition that has to track what
|
||||
`resolve_tariff()` accepts. With no profile and no feed the plan is `no_data`,
|
||||
exactly what the cycle sees without the task.
|
||||
|
||||
**UX-55.** Home said "no prices have arrived" (`mode_runs` None) with every
|
||||
battery on Solar first. The same cause, and this fix clears it for a site with
|
||||
a static profile (`test_tariff24.py` part 5). A site with no profile and no
|
||||
feed still has no prices, and Home still says so.
|
||||
|
||||
## Round 2 (AUDIT-02 F1): the forecast replaces the "no weather" doubt
|
||||
|
||||
**Decided 2026-10-28 (owner), in the same hotfix.** The price gap a mode needs
|
||||
was `cycle_cost_threshold_eur` x 1.5 (`Uncertainty.NO_WEATHER`) whether or not
|
||||
a solar forecast was read. At the shipped cost 0.10 that is 0.15, so a
|
||||
day/night gap (0.08) and many dynamic days never traded, and the screens said
|
||||
only "Prices differ too little".
|
||||
|
||||
**Why the doubt goes with a forecast.** The widening stands in for not knowing
|
||||
the weather. The forecast is that knowledge, and the day plan already trusts it
|
||||
for how much to charge. Trusting it for the plan but doubting it for the gap
|
||||
was one input judged twice.
|
||||
|
||||
**The rule** (`arbitrage.weather_uncertainty()`). With battery modes on and a
|
||||
usable forecast (`solar_fc` not None, the day plan's own test) the doubt is
|
||||
not added: the gap needed is the cycle cost, widened by the price product only.
|
||||
No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three
|
||||
places that must agree: the run plan (`_price_runs()`), the charge gate
|
||||
(`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast
|
||||
is read at most every 60 s and the plan key already holds "forecast or not", so
|
||||
the gap follows in the cycle the read changes, both ways. A run already under
|
||||
way is kept to its end (`priceruns.keep_started()`), as for any change.
|
||||
|
||||
**Not changed.** Modes off (the site trade) keeps the doubt always. The
|
||||
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
|
||||
day/night site must set *Battery cycle cost* to 0.08 or less. Showing the two
|
||||
numbers on the screens (AUDIT-02 F1 option b) is a separate card.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# UX-53 - Home draws a grid charge only where the add-on will take it
|
||||
|
||||
**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).**
|
||||
|
||||
## Decision
|
||||
|
||||
Home asks the add-on's own gate, and does not decide for itself.
|
||||
|
||||
- **Modes on.** `Controller._mode_roles()` files the gate it just asked
|
||||
(`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None
|
||||
when it allowed a charge, else its reason (`peak`, `not_worth`,
|
||||
`not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge
|
||||
and `price_windows()` marks no cheap quarter. Control never reads it. A
|
||||
refusal that is not the gate (no measured headroom, a battery cycling) is
|
||||
not filed, so those pages are as before.
|
||||
- **Modes off.** A charge the verdict puts after its last dear period is
|
||||
refused when its time comes: the quarters left are all equal. `leg_taken()`
|
||||
asks `trade_verdict()` (the control cycle's own call) at the leg's start and
|
||||
keeps the leg only if the gate allows it there. A leg in progress is the
|
||||
verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use
|
||||
the same answer.
|
||||
- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price
|
||||
left" (the verdict reads only the periods not yet over). The flat-tariff
|
||||
refusal calls a site "a fully fixed product" only when its product is fixed.
|
||||
|
||||
## What does not change
|
||||
|
||||
No control decision, command or option. Day/night and hourly sites with a
|
||||
second day of prices keep both legs at every half hour of a day
|
||||
(`test_ux53.py` part 4), except where every price left is equal: with an
|
||||
all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on
|
||||
does not take it. Price-off sites draw nothing, as before.
|
||||
|
||||
## Limits
|
||||
|
||||
The leg's gate is asked on today's plan, as the control cycle will find it at
|
||||
that time. A plan that gets new prices in between is drawn again at the next
|
||||
refresh. While the peak guard holds, a modes-on page shows no grid charge at
|
||||
all, also not the rest of a run the add-on charges in once the guard clears,
|
||||
as the modes-off page already did.
|
||||
@@ -538,7 +538,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
|
||||
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
|
||||
- An expensive hour costs more than the cheap run by at least *Battery cycle cost* (`cycle_cost_threshold_eur`) when a solar forecast is read, and by 1.5 times that when none is (a real-time price feed widens it further). The batteries hold their power until then.
|
||||
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
|
||||
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
|
||||
- A carried (not yet published) price is never used. A run that has started runs to its end.
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
@@ -147,7 +147,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, Uncertainty, price_option
|
||||
from .price import Absence, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
|
||||
@@ -267,19 +267,6 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
return v
|
||||
|
||||
|
||||
def weather_uncertainty(modes_on: bool, solar_fc) -> frozenset:
|
||||
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
|
||||
|
||||
Battery modes read a solar forecast; with one read (`solar_fc` not None) the
|
||||
doubt is gone. Everything else, and the modes without a forecast, keep it.
|
||||
One rule for the run plan, the charge gate and Home's mirror of it. See
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
if modes_on and solar_fc is not None:
|
||||
return frozenset()
|
||||
return frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def soc_bounds(opts: dict) -> tuple:
|
||||
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
|
||||
return (float(price_option(opts, "arbitrage_soc_floor")),
|
||||
|
||||
@@ -157,13 +157,15 @@ class Plan:
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour).
|
||||
|
||||
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
left = timedelta(minutes=60 - lt.minute) - timedelta(
|
||||
seconds=lt.second, microseconds=lt.microsecond)
|
||||
e = min(t.astimezone(timezone.utc) + left, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
@@ -22,7 +22,7 @@ from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, brussels, _cover_table
|
||||
from .price import Absence, Uncertainty, brussels, _cover_table
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
@@ -212,25 +212,26 @@ def _price_mode_in_use(c):
|
||||
return False
|
||||
|
||||
|
||||
def price_windows(c, plan, verdict):
|
||||
def price_windows(c, plan, verdict, now=None):
|
||||
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
|
||||
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
|
||||
(`c.mode_runs`): the cheap run when charging in it pays, and every give
|
||||
run. With them off it is the arbitrage verdict's two points, the ones
|
||||
`trade_runs()` draws. Nothing is classified here."""
|
||||
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
|
||||
allows it (UX-53), and every give run. With them off it is the arbitrage
|
||||
verdict's legs the add-on will take, the ones `trade_runs()` draws
|
||||
(`trade_legs()`, given `now`). Nothing is classified here."""
|
||||
if getattr(c, "modes_on", False):
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or not rp.on or not _price_mode_in_use(c):
|
||||
return []
|
||||
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
|
||||
if rp.charge_useful() else []
|
||||
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap", verdict.cheapest.start,
|
||||
_point_end(plan, verdict.cheapest)),
|
||||
("dear", verdict.dearest.start,
|
||||
_point_end(plan, verdict.dearest))]
|
||||
return [("cheap" if kind == "grid" else "dear", pt.start,
|
||||
_point_end(plan, pt))
|
||||
for kind, pt in trade_legs(c, plan, verdict, now)]
|
||||
|
||||
|
||||
def day_range(points):
|
||||
@@ -277,9 +278,7 @@ def trade_verdict(c, plan, now):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
bool(getattr(c, "modes_on", False)),
|
||||
getattr(c, "solar_fc", None)),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
@@ -308,6 +307,27 @@ def _point_end(plan, point):
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
def leg_taken(c, plan, pt, now):
|
||||
"""UX-53: will the add-on act on this point of the verdict? One in
|
||||
progress is the verdict's own answer now. One still ahead is asked of the
|
||||
same gate (`trade_verdict()`, the call the control cycle makes) at the
|
||||
point's own start. A charge that comes after the last dear period meets a
|
||||
flat remainder there and is refused, so it is not announced. When the gate
|
||||
allows it, the point is still the cheapest (dearest) one: the points
|
||||
before it are gone and it was the first extreme of the points from now."""
|
||||
if now is None or pt.start <= now:
|
||||
return True
|
||||
return trade_verdict(c, plan, pt.start)[0] is not None
|
||||
|
||||
|
||||
def trade_legs(c, plan, verdict, now):
|
||||
"""`[(kind, point)]` by start: the verdict's charge ("grid") and give
|
||||
("give") legs that the add-on will take (`leg_taken()`)."""
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)]
|
||||
|
||||
|
||||
# Under this many kWh a block is not drawn: the cycle would not move it.
|
||||
MIN_KWH = 0.05
|
||||
|
||||
@@ -324,10 +344,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
runs, later = [], []
|
||||
for kind, pt in legs:
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
@@ -373,8 +391,9 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
them (`c.mode_runs`): the cheap run on the batteries that may charge
|
||||
from the grid, the give runs on Leftover solar and Cheap hours, and the
|
||||
sell run on those with selling on. Amounts are upper bounds ("up to"),
|
||||
from the battery's own room, rating and the run's length. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
from the battery's own room, rating and the run's length. UX-53: no grid
|
||||
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
|
||||
control cycle found). Returns `(runs, no_trade_key)`."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
@@ -383,6 +402,7 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
if not rp.on:
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
gate = getattr(c, "mode_gate", None)
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
@@ -399,8 +419,9 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
blocks = []
|
||||
# P4: with a day plan, the grid block is the planned shortfall.
|
||||
need = plan_need(c, inv.name)
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
if gate is None and rp.charge_useful() and (
|
||||
need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -610,7 +631,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh, now)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
|
||||
@@ -174,17 +174,57 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
|
||||
Yes with a price feed, with trading on price armed, or with battery modes
|
||||
on: the modes read `self.tariff_plan` for their price runs. With
|
||||
`price_provider` off the refresh is pure arithmetic on `static_profile`, so
|
||||
no term here adds an HTTPS call. It decides whether prices are looked up,
|
||||
never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md
|
||||
and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
"""
|
||||
return (provider_configured(opts) or arbitrage.enabled(opts)
|
||||
or packmode.enabled(opts))
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
@@ -961,6 +1001,10 @@ class Controller:
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
self.mode_tick = None
|
||||
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
|
||||
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
|
||||
# it; nothing in control does.
|
||||
self.mode_gate = None
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
@@ -2250,7 +2294,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| arbitrage.weather_uncertainty(True, self.solar_fc))
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2329,7 +2373,7 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=arbitrage.weather_uncertainty(True, self.solar_fc),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
now=now).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
@@ -2340,6 +2384,7 @@ class Controller:
|
||||
charge_ok = sell_ok and allowed(guard)
|
||||
trade_why = (None if charge_ok else "peak" if sell_ok
|
||||
else "not_worth")
|
||||
self.mode_gate = None if charge_ok else trade_why
|
||||
price_now = None
|
||||
if plan is not None:
|
||||
try:
|
||||
@@ -8011,9 +8056,11 @@ async def amain() -> None:
|
||||
# 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 FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
|
||||
# see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
|
||||
# TARIFF-24 for why each term is there.
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
|
||||
@@ -1103,8 +1103,12 @@ class TariffPlan:
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
f"selling high is arithmetically impossible"
|
||||
# UX-53: only a real fixed product is "fully fixed". A
|
||||
# dynamic or day/night site whose remaining periods happen to
|
||||
# be equal trades again when they differ.
|
||||
+ (". §9.2: a fully fixed product is self-consumption only"
|
||||
if self.product is PriceProduct.FIXED else ""))
|
||||
|
||||
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
|
||||
# of active uncertainty sources: the product's own (§9.2 row 2, derived
|
||||
|
||||
@@ -367,11 +367,11 @@
|
||||
"run": {
|
||||
"grid_what": "Charges up to {kwh} from the grid.",
|
||||
"grid_what_would": "Would charge up to {kwh} from the grid.",
|
||||
"grid_why": "Lowest published price, {eur} per kWh.",
|
||||
"grid_why": "Lowest price left, {eur} per kWh.",
|
||||
"grid_amount": "Costs up to {eur}.",
|
||||
"give_what": "Gives up to {kwh}, to the house first.",
|
||||
"give_what_would": "Would give up to {kwh}, to the house first.",
|
||||
"give_why": "Highest published price, {eur} per kWh.",
|
||||
"give_why": "Highest price left, {eur} per kWh.",
|
||||
"sell_what": "Sells up to {kwh}, to the house first.",
|
||||
"sell_what_would": "Would sell up to {kwh}, to the house first.",
|
||||
"cheap_why": "Cheapest hours, about {eur} per kWh.",
|
||||
|
||||
@@ -307,11 +307,11 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
|
||||
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
|
||||
"grid_amount": "Kost tot {eur}.",
|
||||
"give_what": "Levert tot {kwh}, eerst aan het huis.",
|
||||
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
|
||||
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
|
||||
"give_why": "Hoogste prijs die nog over is, {eur} per kWh.",
|
||||
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
|
||||
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
|
||||
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.1"
|
||||
version: "0.19.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -523,7 +523,7 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
"lowest are the same 15-minute period",
|
||||
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
||||
and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} "
|
||||
"per kWh."
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
|
||||
@@ -540,12 +540,6 @@ rp.Hass.state = _sensor
|
||||
rp_start = [UTC0]
|
||||
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"cycle_cost_threshold_eur": 0.20}
|
||||
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
|
||||
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
|
||||
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
|
||||
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
|
||||
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
|
||||
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
|
||||
|
||||
|
||||
def seed_profile(c):
|
||||
@@ -603,8 +597,7 @@ def invariants(tag, rows, c):
|
||||
|
||||
|
||||
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
@@ -630,8 +623,7 @@ check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||
for r in charge))
|
||||
|
||||
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("cheap hours, partly", rows, c)
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||
@@ -656,8 +648,7 @@ check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
@@ -667,7 +658,7 @@ check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",), extra=GATE_030)
|
||||
refuse=("b",))
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
"""SAFETY-123: the day plan's local-hour loop ends on the clock-change days.
|
||||
`python3 test_safety123.py`
|
||||
|
||||
On a host with no tzdata (`tariff._EUTimezone`) `dayplan._hour_slices()` never
|
||||
returned on the spring clock change (28 March 2027, 00:00 UTC): the control
|
||||
tick froze. Graded here, each call under a hard time limit (SIGALRM):
|
||||
|
||||
1. `_hour_slices()` over 27-29 March and 30 Oct - 1 Nov 2027, and the US
|
||||
change days, at every quarter, in the no-tzdata zone, a US-rule zone built
|
||||
the same way, and (where the host has tzdata) Europe/Brussels,
|
||||
America/New_York and zones with half-hour changes: it ends, covers the
|
||||
span, never crosses a local hour, labels the local hour, and gives 23
|
||||
slices on the spring day and 25 on the autumn day.
|
||||
2. Normal days: the same slices as release/1.0 a1d0703 (a copy of its
|
||||
function is kept below as the reference).
|
||||
3. `dayplan.plan()` at every quarter of those days with each pack mode, and
|
||||
its answer on normal days equal to the old function's.
|
||||
4. `Controller._mode_roles()` at every quarter of those days with modes on,
|
||||
for Solar first, Leftover solar and Cheap hours, and one full control
|
||||
cycle through the forecast read at the hang's instants.
|
||||
See DECISIONS/SAFETY-123-hour-slices-step-in-utc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import signal
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import BILLING_TZ, _EUTimezone # noqa: E402
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
LIMIT_S = 3.0 # one call; the old loop never returned
|
||||
try: # a loop that appends without end must not take the host
|
||||
resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
|
||||
resource.RLIMIT_AS)[1]))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class Slow(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _alarm(*_a):
|
||||
raise Slow()
|
||||
|
||||
|
||||
def limited(fn, *a, **k):
|
||||
"""`fn(*a, **k)` or `Slow` when it takes more than LIMIT_S seconds."""
|
||||
signal.signal(signal.SIGALRM, _alarm)
|
||||
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
|
||||
try:
|
||||
return fn(*a, **k)
|
||||
except MemoryError:
|
||||
raise Slow() from None
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The reference: release/1.0 a1d0703's function, verbatim, for normal days
|
||||
# --------------------------------------------------------------------------- #
|
||||
def old_slices(t0, t1):
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = dayplan._local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zones
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _nth_sunday(year, month, n):
|
||||
d = date(year, month, 1)
|
||||
d += timedelta(days=(6 - d.weekday()) % 7 + 7 * (n - 1))
|
||||
return datetime(d.year, d.month, d.day)
|
||||
|
||||
|
||||
class USZone(tzinfo):
|
||||
"""America/New_York by the US rule (DST from the second Sunday of March
|
||||
02:00 to the first Sunday of November 02:00, local), built like
|
||||
tariff._EUTimezone: the offset is read from the local wall clock, so the
|
||||
missing 02:00 reads as DST. Its change days are not the EU's."""
|
||||
|
||||
def _dst_local(self, naive):
|
||||
y = naive.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=2) <= naive
|
||||
< _nth_sunday(y, 11, 1).replace(hour=2))
|
||||
|
||||
def _dst_utc(self, naive_utc):
|
||||
y = naive_utc.year
|
||||
return (_nth_sunday(y, 3, 2).replace(hour=7) <= naive_utc
|
||||
< _nth_sunday(y, 11, 1).replace(hour=6))
|
||||
|
||||
def fromutc(self, dt):
|
||||
return dt + timedelta(
|
||||
hours=-4 if self._dst_utc(dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(hours=-5)
|
||||
return timedelta(hours=-4 if self._dst_local(
|
||||
dt.replace(tzinfo=None)) else -5)
|
||||
|
||||
def dst(self, dt):
|
||||
return timedelta(hours=1) if dt is not None and self._dst_local(
|
||||
dt.replace(tzinfo=None)) else timedelta(0)
|
||||
|
||||
|
||||
def _q(y, mo, d, h=0):
|
||||
return datetime(y, mo, d, h, tzinfo=UTC)
|
||||
|
||||
|
||||
# (name, tzinfo, whole-hour offsets?, windows [from, to) at every quarter)
|
||||
BRU_SPRING = (_q(2027, 3, 27), _q(2027, 3, 30))
|
||||
BRU_AUTUMN = (_q(2027, 10, 30), _q(2027, 11, 2))
|
||||
US_SPRING = (_q(2027, 3, 13), _q(2027, 3, 16))
|
||||
US_AUTUMN = (_q(2027, 11, 6), _q(2027, 11, 9))
|
||||
COMMON = [BRU_SPRING, BRU_AUTUMN, US_SPRING, US_AUTUMN]
|
||||
ZONES = [("no-tzdata EU rule", _EUTimezone(), True, COMMON),
|
||||
("US rule", USZone(), True, COMMON)]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
for _n, _w, _extra in (
|
||||
("Europe/Brussels", True, []),
|
||||
("America/New_York", True, []),
|
||||
# DST of half an hour, and clocks that jump at midnight
|
||||
("Australia/Lord_Howe", False,
|
||||
[(_q(2027, 4, 3), _q(2027, 4, 6)),
|
||||
(_q(2027, 10, 2), _q(2027, 10, 5))]),
|
||||
("America/Santiago", True,
|
||||
[(_q(2027, 4, 2), _q(2027, 4, 5)),
|
||||
(_q(2027, 9, 4), _q(2027, 9, 7))]),
|
||||
("Asia/Kathmandu", False, [])):
|
||||
try:
|
||||
ZONES.append((_n, ZoneInfo(_n), _w, COMMON + _extra))
|
||||
except Exception: # noqa: BLE001
|
||||
print(f" no tzdata for {_n} here: not graded")
|
||||
except ImportError:
|
||||
print(" no zoneinfo here")
|
||||
print(" zones graded:", ", ".join(z[0] for z in ZONES))
|
||||
|
||||
|
||||
class In:
|
||||
"""`dayplan.BILLING_TZ` is `tz` inside the with-block."""
|
||||
|
||||
def __init__(self, tz):
|
||||
self.tz = tz
|
||||
|
||||
def __enter__(self):
|
||||
self.was = dayplan.BILLING_TZ
|
||||
dayplan.BILLING_TZ = self.tz
|
||||
|
||||
def __exit__(self, *_a):
|
||||
dayplan.BILLING_TZ = self.was
|
||||
|
||||
|
||||
def quarters(a, b):
|
||||
t = a
|
||||
while t < b:
|
||||
yield t
|
||||
t += timedelta(minutes=15)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. _hour_slices(): ends, covers, local hours, 23 and 25 hours")
|
||||
HOUR = timedelta(hours=1)
|
||||
for zname, tz, whole, windows in ZONES:
|
||||
bad, slow, n = [], None, 0
|
||||
with In(tz):
|
||||
for a, b in windows:
|
||||
for t0 in quarters(a, b):
|
||||
for span in (HOUR, 7 * HOUR, 30 * HOUR):
|
||||
t1 = t0 + span
|
||||
n += 1
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, t0, t1)
|
||||
except Slow:
|
||||
slow = (t0, t1)
|
||||
break
|
||||
ok = bool(sl) and sl[0][0] == t0 and sl[-1][1] == t1
|
||||
ok = ok and all(x[1] == y[0] for x, y in zip(sl, sl[1:]))
|
||||
for s, e, h in sl:
|
||||
ok = ok and timedelta(0) < e - s <= HOUR
|
||||
lt = s.astimezone(tz)
|
||||
ok = ok and h == lt.hour
|
||||
last = (e - timedelta(microseconds=1)).astimezone(tz)
|
||||
# no local hour boundary inside the slice
|
||||
ok = ok and (lt.year, lt.month, lt.day, lt.hour) == (
|
||||
last.year, last.month, last.day, last.hour)
|
||||
# it ends on a local hour, the span's end, or where
|
||||
# the clock itself jumps (half-hour changes)
|
||||
el = e.astimezone(tz)
|
||||
ok = ok and (e == t1 or el.minute == 0
|
||||
or el.utcoffset() != lt.utcoffset())
|
||||
if whole:
|
||||
# whole-hour zones: local hours are UTC hours
|
||||
ok = ok and (e.minute == 0 or e == t1) \
|
||||
and (s.minute == 0 or s == t0)
|
||||
if not ok:
|
||||
bad.append((t0, t1))
|
||||
if slow:
|
||||
break
|
||||
if slow:
|
||||
break
|
||||
check(f"{zname}: every quarter x 1, 7, 30 h ({n} spans) returns within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow[0]}"),
|
||||
slow is None)
|
||||
check(f"{zname}: ...contiguous from t0 to t1, each slice at most an "
|
||||
f"hour, labelled with its local hour, none across a local hour",
|
||||
not bad)
|
||||
|
||||
# The change days: 23 and 25 local hours, from local midnight to midnight.
|
||||
# (spring day, autumn day, the local hour that happens twice in autumn)
|
||||
BRU_DAYS = ((2027, 3, 28), (2027, 10, 31), 2)
|
||||
US_DAYS = ((2027, 3, 14), (2027, 11, 7), 1)
|
||||
DAYS = {"no-tzdata EU rule": BRU_DAYS, "Europe/Brussels": BRU_DAYS,
|
||||
"US rule": US_DAYS, "America/New_York": US_DAYS}
|
||||
|
||||
|
||||
def local_day(tz, y, m, d):
|
||||
d0 = datetime(y, m, d, tzinfo=tz).astimezone(UTC)
|
||||
n = date(y, m, d) + timedelta(days=1)
|
||||
return d0, datetime(n.year, n.month, n.day, tzinfo=tz).astimezone(UTC)
|
||||
|
||||
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
if zname not in DAYS:
|
||||
continue
|
||||
spring, autumn, twice = DAYS[zname]
|
||||
with In(tz):
|
||||
for what, (y, m, d), want, want_hours in (
|
||||
("spring", spring, 23, [h for h in range(24) if h != 2]),
|
||||
("autumn", autumn, 25,
|
||||
[h for h in range(24) for _ in range(2 if h == twice else 1)])):
|
||||
d0, d1 = local_day(tz, y, m, d)
|
||||
try:
|
||||
sl = limited(dayplan._hour_slices, d0, d1)
|
||||
except Slow:
|
||||
sl = None
|
||||
check(f"{zname}, {what} day {y}-{m:02d}-{d:02d}: {want} one-hour "
|
||||
f"slices from local midnight to midnight",
|
||||
sl is not None and len(sl) == want
|
||||
and all(e - s == HOUR for s, e, _h in sl)
|
||||
and d1 - d0 == want * HOUR and sl[0][0] == d0
|
||||
and sl[-1][1] == d1)
|
||||
check(f"{zname}, {what} day: the local hours are "
|
||||
f"{'0-23 without 2' if what == 'spring' else f'0-23 with {twice} twice'}",
|
||||
sl is not None and [h for _s, _e, h in sl] == want_hours)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. normal days: the same slices as release/1.0 a1d0703")
|
||||
NORMAL = [(_q(2027, 1, 9), _q(2027, 1, 12)), (_q(2027, 6, 18), _q(2027, 6, 21)),
|
||||
(_q(2027, 8, 30), _q(2027, 9, 2)), (_q(2027, 12, 18), _q(2027, 12, 21))]
|
||||
for zname, tz, _w, _win in ZONES:
|
||||
diff, n = [], 0
|
||||
with In(tz):
|
||||
for a, b in NORMAL:
|
||||
for t0 in quarters(a, b):
|
||||
for off in (timedelta(0), timedelta(seconds=7, microseconds=5),
|
||||
timedelta(minutes=14, seconds=59, microseconds=999999),
|
||||
timedelta(minutes=37, seconds=1)):
|
||||
for span in (timedelta(minutes=1), timedelta(minutes=50),
|
||||
3 * HOUR, 26 * HOUR):
|
||||
s0 = t0 + off
|
||||
n += 1
|
||||
if dayplan._hour_slices(s0, s0 + span) != old_slices(
|
||||
s0, s0 + span):
|
||||
diff.append(s0)
|
||||
check(f"{zname}: {n} spans on ordinary days equal the old slices "
|
||||
f"(including starts off the quarter)", n > 1000 and not diff)
|
||||
with In(ZONES[0][1]):
|
||||
s0 = _q(2027, 1, 10, 9)
|
||||
eq = dayplan._hour_slices(s0, s0) == [] == old_slices(s0, s0) \
|
||||
and dayplan._hour_slices(s0, s0 - HOUR) == []
|
||||
check("an empty or backwards span gives no slices, as before", eq)
|
||||
plus2 = timezone(timedelta(hours=2))
|
||||
t0 = datetime(2027, 6, 1, 10, 20, tzinfo=plus2)
|
||||
check("a start given in another zone still gives the old slices",
|
||||
dayplan._hour_slices(t0, t0 + 3 * HOUR) ==
|
||||
old_slices(t0, t0 + 3 * HOUR))
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. dayplan.plan() at every quarter of the change days")
|
||||
|
||||
|
||||
def st(kwh, at):
|
||||
return {"state": str(kwh), "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def cheap_dear(h):
|
||||
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
|
||||
|
||||
|
||||
def price_plan(now, f=cheap_dear, days=4):
|
||||
"""15-minute firm prices from a day before `now` for `days` days."""
|
||||
start = (now - timedelta(days=1)).replace(minute=0, second=0,
|
||||
microsecond=0)
|
||||
pts = []
|
||||
for i in range(days * 96 + 96):
|
||||
b = brussels(start + timedelta(minutes=15 * i))
|
||||
pts.append(PricePoint(start=b, eur_per_kwh=f(b.hour + b.minute / 60),
|
||||
provisional=False))
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
|
||||
|
||||
_PLANS = {}
|
||||
|
||||
|
||||
def plan_for(start):
|
||||
"""One price plan covering `start` - 1 day .. + 5 days, kept: building it
|
||||
for every quarter is most of the run time."""
|
||||
if start not in _PLANS:
|
||||
_PLANS[start] = price_plan(start, days=6)
|
||||
return _PLANS[start]
|
||||
|
||||
|
||||
HOUSE = dayplan.HouseProfile({"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}})
|
||||
MODES = {"Solar first": (packmode.SOLAR_FIRST, packmode.SOLAR_FIRST),
|
||||
"Leftover solar": (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR),
|
||||
"Cheap hours": (packmode.CHEAP_HOURS, packmode.CHEAP_HOURS)}
|
||||
|
||||
|
||||
def packs_of(modes, soc=(55.0, 40.0)):
|
||||
return [modetrade.Pack(f"inverter-{u}", True, mode, False, 2500.0, s, 10.0)
|
||||
for u, mode, s in zip("ab", modes, soc)]
|
||||
|
||||
|
||||
_INPUTS = {}
|
||||
|
||||
|
||||
def plan_at(now, modes, start=None):
|
||||
"""plan() on the price runs and forecast of `now` (made once per quarter:
|
||||
the runs do not depend on the pack modes)."""
|
||||
if now not in _INPUTS:
|
||||
pts = plan_for(start or now.replace(hour=0, minute=0)).read.points
|
||||
_INPUTS[now] = (
|
||||
priceruns.plan(pts, now, required_eur_kwh=0.1, charge_kwh=8.0,
|
||||
charge_kw=5.0, sell_kwh=0.0, sell_kw=0.0),
|
||||
solarfc.read(st(2.0, now), st(8.0, now), now)[0])
|
||||
runs, fc = _INPUTS[now]
|
||||
return dayplan.plan(fc, HOUSE.estimator(), runs, now, packs_of(modes),
|
||||
trust_pct=70, floor_pct=20, ceiling_pct=95)
|
||||
|
||||
|
||||
spy = []
|
||||
_slices = dayplan._hour_slices
|
||||
dayplan._hour_slices = lambda a, b: (spy.append((a, b)), _slices(a, b))[1]
|
||||
try:
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
made, slow, n = 0, None, 0
|
||||
spy.clear()
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
try:
|
||||
p = limited(plan_at, now, modes, a)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += p is not None
|
||||
check(f"plan(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else
|
||||
f" - HUNG at {slow}")
|
||||
+ f"; a plan is made at {made} of them", slow is None
|
||||
and made > 20)
|
||||
if wname == "27-29 March" and not slow:
|
||||
cross = [s for s in spy if s[0] <= _q(2027, 3, 28) < s[1]]
|
||||
check(f"plan(), {mname}: {len(cross)} spans ran across the "
|
||||
"change (28 March 00:00 UTC): the test reaches the "
|
||||
"loop", len(cross) > 20)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
|
||||
# The answer on normal days is the old function's.
|
||||
for mname, modes in MODES.items():
|
||||
same, n = True, 0
|
||||
for now in quarters(_q(2027, 1, 11), _q(2027, 1, 13)):
|
||||
new = plan_at(now, modes)
|
||||
dayplan._hour_slices = old_slices
|
||||
try:
|
||||
was = plan_at(now, modes)
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
n += 1
|
||||
same = same and new == was
|
||||
check(f"plan(), {mname}: {n} quarters on ordinary days equal the old "
|
||||
"answer", same)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. Controller._mode_roles() at every quarter, modes on")
|
||||
|
||||
|
||||
def controller(modes, now):
|
||||
extra = {"battery_modes_enabled": True, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.05,
|
||||
"pv_forecast_entities": ["sensor.t1", "sensor.t2", "sensor.t3"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.m1", "sensor.m2",
|
||||
"sensor.m3"]}
|
||||
opts = rig._options(2, extra)
|
||||
for i in opts["inverters"]:
|
||||
i["capacity_kwh"] = 10.0
|
||||
store = rig._Store()
|
||||
for u, mode in zip("ab", modes):
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data["house_profile"] = {"2027-03-20": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
|
||||
|
||||
class Hass(rig._Hass):
|
||||
async def state(self, e):
|
||||
if e.startswith(("sensor.t", "sensor.m")):
|
||||
return st(2.0 if e.startswith("sensor.t") else 8.0, self.at)
|
||||
return await super().state(e)
|
||||
hass = Hass(("a", "b"), lambda u, t: 55.0 if u == "a" else 40.0,
|
||||
lambda u, t: False)
|
||||
hass.at = now
|
||||
c = M.Controller(opts, hass, store, rig._Pub(), stop=asyncio.Event())
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
i.soc = 55.0 if i.name.endswith("a") else 40.0
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
for mname, modes in MODES.items():
|
||||
for wname, (a, b) in (("27-29 March", BRU_SPRING),
|
||||
("30 Oct-1 Nov", BRU_AUTUMN)):
|
||||
c, hass = controller(modes, a)
|
||||
slow, n, made, roles = None, 0, 0, set()
|
||||
spy.clear()
|
||||
dayplan._hour_slices = lambda x, y: (spy.append((x, y)),
|
||||
_slices(x, y))[1]
|
||||
try:
|
||||
for now in quarters(a, b):
|
||||
n += 1
|
||||
c.tariff_plan, c.tariff_at = plan_for(a), now
|
||||
c.solar_fc, _w, _g = solarfc.read_sum(
|
||||
[("sensor.t1", st(2.0, now))],
|
||||
[("sensor.m1", st(8.0, now))], now)
|
||||
try:
|
||||
t = limited(c._mode_roles, now, None)
|
||||
except Slow:
|
||||
slow = now
|
||||
break
|
||||
made += c.day_plan is not None
|
||||
roles |= set(t.roles.values())
|
||||
finally:
|
||||
dayplan._hour_slices = _slices
|
||||
check(f"_mode_roles(), {mname}, {wname}: {n} quarters return within "
|
||||
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}"),
|
||||
slow is None)
|
||||
check(f"...a day plan is made at {made} of them (the loop is "
|
||||
f"reached), roles seen {sorted(roles)}", slow is None
|
||||
and made > 20 and len(spy) > 20)
|
||||
|
||||
# One whole control cycle through the real forecast read (three sensors a
|
||||
# day) at the instants the old loop hung: the page and control share the loop.
|
||||
for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
"2027-03-28T00:00:05+00:00"):
|
||||
now = datetime.fromisoformat(iso)
|
||||
c, hass = controller(MODES["Leftover solar"], now)
|
||||
real_dt = M.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None, _n=now):
|
||||
return _n if tz is None else _n.astimezone(tz)
|
||||
M.datetime = _DT
|
||||
try:
|
||||
async def one():
|
||||
await c.read_inputs(now_mono=0.0)
|
||||
c.tariff_plan, c.tariff_at = price_plan(now), now
|
||||
await c.cycle()
|
||||
try:
|
||||
limited(lambda: asyncio.run(one()))
|
||||
ok = c.solar_fc is not None and c.day_plan is not None
|
||||
except Slow:
|
||||
ok = False
|
||||
finally:
|
||||
M.datetime = real_dt
|
||||
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
|
||||
"day) returns with a day plan", ok)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -1,374 +0,0 @@
|
||||
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
|
||||
|
||||
Battery modes on, a day/night `static_profile`, no price feed and the site trade
|
||||
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
|
||||
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
|
||||
grid, and the Batteries screen said "No prices" while Home priced the day from
|
||||
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
|
||||
`amain()` really puts on the task (read from its source, not retyped).
|
||||
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
|
||||
started only if `amain()`'s own guard says so: night period, one battery
|
||||
Leftover solar and one Cheap hours.
|
||||
3. The same with a gap below the cycle cost.
|
||||
4. Home and the Batteries screen agree.
|
||||
5. UX-55: with every battery Solar first Home no longer says "no prices have
|
||||
arrived" on a static profile, and still does with no profile and no feed.
|
||||
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
|
||||
the arbitrage and feed cases as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the gate
|
||||
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
|
||||
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
|
||||
dropping the arbitrage term fails the arbitrage rows.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, packmode # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
from app.main import Controller, tariff_refresh_wanted # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
|
||||
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.22},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.30}]}')
|
||||
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
|
||||
cost=0.05):
|
||||
o = dict(rp.options(modes, arb, 2))
|
||||
o["cycle_cost_threshold_eur"] = cost
|
||||
if profile is not None:
|
||||
o["static_profile"] = profile
|
||||
if provider is not None:
|
||||
o["price_provider"] = provider
|
||||
return o
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The guard amain() puts on the tariff task, read from amain()'s source.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _guards_of(node, guards=()):
|
||||
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
|
||||
out = []
|
||||
for child in ast.iter_child_nodes(node):
|
||||
if isinstance(child, ast.If):
|
||||
test = ast.unparse(child.test)
|
||||
for stmt in child.body:
|
||||
out += _guards_of(stmt, guards + (test,))
|
||||
for stmt in child.orelse:
|
||||
out += _guards_of(stmt, guards + (f"not ({test})",))
|
||||
continue
|
||||
if isinstance(child, ast.Call) and \
|
||||
ast.unparse(child.func).endswith("create_task") and child.args:
|
||||
arg = child.args[0]
|
||||
name = (arg.func.attr if isinstance(arg, ast.Call)
|
||||
and isinstance(arg.func, ast.Attribute)
|
||||
else ast.unparse(arg))
|
||||
out.append((guards, name))
|
||||
continue
|
||||
out += _guards_of(child, guards)
|
||||
return out
|
||||
|
||||
|
||||
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read())
|
||||
_AMAIN = first((n for n in ast.walk(_TREE)
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
|
||||
"amain() in main.py")
|
||||
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
|
||||
|
||||
|
||||
def task_started(o) -> bool:
|
||||
"""Would amain() start the tariff refresh task for options `o`?"""
|
||||
if len(_TASK_GUARDS) != 1:
|
||||
return False
|
||||
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the startup gate")
|
||||
check("amain() starts the tariff refresh from exactly one guard, and that "
|
||||
"guard can be decided from the options alone",
|
||||
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
|
||||
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
|
||||
for g in _TASK_GUARDS[0]))
|
||||
GATE = [
|
||||
# label, options, task wanted
|
||||
("modes on + static day/night only", opts(), True),
|
||||
("modes on + a fixed price only",
|
||||
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
|
||||
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
|
||||
("modes on, nothing else", opts(profile=None), True),
|
||||
("modes off + arbitrage off + no feed (static profile)",
|
||||
opts(modes=False), False),
|
||||
("modes off + arbitrage off + no feed, no profile",
|
||||
opts(modes=False, profile=None), False),
|
||||
("modes off + arbitrage on", opts(modes=False, arb=True), True),
|
||||
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
|
||||
True),
|
||||
("modes on + a feed", opts(provider="generic_dayahead"), True),
|
||||
("modes on + arbitrage on", opts(arb=True), True),
|
||||
]
|
||||
for label, o, want in GATE:
|
||||
got = tariff_refresh_wanted(o)
|
||||
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
|
||||
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
|
||||
check(f"{label}: amain() starts the task exactly as that says",
|
||||
task_started(o) is want)
|
||||
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
|
||||
"'true' as text or 1 does not start the task",
|
||||
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
|
||||
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The real Controller over a few minutes of a night: the task starts only if
|
||||
# amain()'s own guard says so; then read_inputs() + cycle() as the control
|
||||
# loop runs them.
|
||||
# --------------------------------------------------------------------------- #
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
|
||||
|
||||
FETCHES = []
|
||||
|
||||
|
||||
async def _no_fetch(*a, **k):
|
||||
FETCHES.append(a)
|
||||
raise AssertionError("the tariff task reached for a price feed")
|
||||
|
||||
|
||||
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
house_w=300.0, tick=5.0):
|
||||
"""-> (controller, rows, task_was_started)."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in choice.items():
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
|
||||
rows = []
|
||||
|
||||
async def go():
|
||||
hass = rp.Hass(("a", "b"), lambda t: house_w,
|
||||
soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(),
|
||||
stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
_real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
started = task_started(o)
|
||||
task = (asyncio.create_task(c.run_tariff_refresh())
|
||||
if started else None)
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0 and started:
|
||||
for _ in range(500): # let the task do its first pass
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rows.append(dict(
|
||||
now=now, runs=c.mode_runs, tick=c.mode_tick,
|
||||
plan=c.fresh_tariff_plan(now),
|
||||
action=getattr(c.arb, "action", None),
|
||||
p={u: p.p for u, p in hass.plants.items()},
|
||||
soc={u: p.soc for u, p in hass.plants.items()}))
|
||||
t += tick
|
||||
# What the two screens say at the end, while the clock is still
|
||||
# the simulated one.
|
||||
c.home_plan = homeview.build_plan(c, clock[0], False, True)
|
||||
c.battery_cards = homeview.battery_rows(c, False, True)
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
_price.load_prices, _st.load_prices = _real_load
|
||||
return c, started
|
||||
|
||||
c, started = asyncio.run(go())
|
||||
return c, rows, started
|
||||
|
||||
|
||||
def charging_b(rows):
|
||||
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
|
||||
a 300 W house in this scenario)."""
|
||||
return [r for r in rows if r["p"]["b"] < -200.0]
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.battery_cards if b["name"] == name),
|
||||
f"a card for {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
|
||||
c1, r1, started1 = drive(opts())
|
||||
_last = nth(r1, -1, "the last cycle")
|
||||
_first1 = nth(r1, 0, "the first cycle")
|
||||
print(f" tariff task started by amain's guard: {started1}")
|
||||
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
|
||||
print(f" mode_runs: {_last['runs']}")
|
||||
print(f" mode_tick reason / roles: "
|
||||
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
|
||||
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
|
||||
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
|
||||
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
|
||||
check("the tariff refresh task is started", started1)
|
||||
check("...and it adds no network call: with price_provider off the feed "
|
||||
"loader is never reached (it is replaced by one that fails)",
|
||||
not FETCHES and not _price.provider_configured(opts()))
|
||||
check("a fresh tariff plan with prices is held", present(_last)
|
||||
and _last["plan"] is not None and _last["plan"].has_prices)
|
||||
check("the price runs exist (mode_runs is not None) and carry a cheap run "
|
||||
"that holds in the night period",
|
||||
present(_last) and _last["runs"] is not None and _last["runs"].on
|
||||
and _last["runs"].cheap is not None
|
||||
and _last["runs"].cheap.holds(_last["now"]))
|
||||
check("the Cheap hours battery charges from the grid in the night period",
|
||||
len(charging_b(r1)) > len(r1) // 2
|
||||
and present(_last, _first1)
|
||||
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
|
||||
check("...and the Leftover solar battery does not (no sun, no forecast)",
|
||||
not any(r["p"]["a"] < -200.0 for r in r1))
|
||||
check("...the reason is not 'no prices': neither battery reads no_prices",
|
||||
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
|
||||
check("...only the Cheap hours battery got the grid charge role",
|
||||
present(_last) and _last["tick"] is not None
|
||||
and _last["tick"].roles.get(B) == modetrade.CHARGE
|
||||
and _last["tick"].roles.get(A) != modetrade.CHARGE)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. the same with a gap below the cycle cost")
|
||||
c3, r3, started3 = drive(opts(cost=0.20))
|
||||
_l3 = nth(r3, -1, "the last cycle")
|
||||
print(f" started {started3}; mode_runs {_l3['runs']}; "
|
||||
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
|
||||
check("the task is started and the plan is there, so mode_runs is not None",
|
||||
started3 and present(_l3) and _l3["plan"] is not None
|
||||
and _l3["runs"] is not None)
|
||||
check("the reason is the gap: no_spread ('Prices differ too little'), "
|
||||
"never no_prices, on both batteries",
|
||||
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
|
||||
and present(_l3) and _l3["runs"] is not None
|
||||
and not _l3["runs"].on)
|
||||
check("...and no battery charges from the grid",
|
||||
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. Home and the Batteries screen agree")
|
||||
hp = c1.home_plan
|
||||
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
|
||||
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
|
||||
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
|
||||
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
|
||||
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
|
||||
check("Home prices the day, and the Batteries screen does not say it has no "
|
||||
"prices", hp["has_prices"] and _bars
|
||||
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
|
||||
check("Home's plan has the grid charge block for the Cheap hours battery "
|
||||
"(the 'trade' verdict) and no 'no prices have arrived' line",
|
||||
hp["verdict_key"] == "trade"
|
||||
and any(r["kind"] == "grid" for r in hp["runs"])
|
||||
and not any("no prices have arrived" in ln for ln in hp["lines"]))
|
||||
hp3 = c3.home_plan
|
||||
check("with the gap too small both say so: Home 'price gap is too small', "
|
||||
"the batteries 'differ too little'; neither says no prices",
|
||||
any("price gap is too small" in ln for ln in hp3["lines"])
|
||||
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
|
||||
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
|
||||
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
|
||||
print(f" Home lines: {c5.home_plan['lines']}")
|
||||
check("Home no longer says 'no prices have arrived' with every battery Solar "
|
||||
"first on a static profile",
|
||||
s5 and not any("no prices have arrived" in ln
|
||||
for ln in c5.home_plan["lines"]))
|
||||
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
|
||||
print(f" no profile, no feed: started {s5b}, Home lines "
|
||||
f"{c5b.home_plan['lines']}")
|
||||
check("...and with no profile and no feed there are truly no prices, and "
|
||||
"Home still says so",
|
||||
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. nothing else moved")
|
||||
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
|
||||
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
|
||||
"(as before)", not s6 and c6.tariff_plan is None
|
||||
and all(r["runs"] is None for r in r6))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,354 +0,0 @@
|
||||
"""Runnable check for TARIFF-24 round 2 (AUDIT-02 F1). `python3 test_tariff24_weather.py`
|
||||
|
||||
With battery modes on, the price gap a mode needs was always widened x1.5
|
||||
(`Uncertainty.NO_WEATHER`) even with a solar forecast read. Now the doubt is
|
||||
added only when no usable forecast is read (`arbitrage.weather_uncertainty()`),
|
||||
in the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's
|
||||
mirror (`homeview.trade_verdict()`). Modes off is untouched.
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
|
||||
1. The rule itself, and that modes off keeps the doubt.
|
||||
2. Day/night 0.34/0.26 (gap 0.08), modes on, real Controller, night period:
|
||||
with a forecast at cycle cost 0.07 the cheap run is planned and the Cheap
|
||||
hours battery charges; without one, or with a stale one, the same cost needs
|
||||
0.105 and the reason is `not_worth` / "Prices differ too little".
|
||||
3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
|
||||
without.
|
||||
4. The forecast going and coming mid-run: the gate follows in the same cycle
|
||||
the forecast read changes, both ways.
|
||||
5. Home agrees with control in each case (Batteries Why, Home line, and
|
||||
`trade_verdict()`, Home's mirror of the gate).
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): the doubt always
|
||||
added (the old code) fails parts 2 to 5; never added fails the without-forecast
|
||||
rows; Home's mirror left on the old rule fails part 5.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
L = timezone(timedelta(hours=1))
|
||||
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
|
||||
|
||||
def opts(cost, *, arb=False, sensors=True):
|
||||
o = dict(rp.options(True, arb, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE)
|
||||
if sensors:
|
||||
o.update(pv_forecast_entity=FC_TODAY,
|
||||
pv_forecast_tomorrow_entity=FC_TOMORROW)
|
||||
return o
|
||||
|
||||
|
||||
def dynamic_points(start):
|
||||
"""A dynamic day-ahead shape, 0.16 .. 0.28 (gap 0.12), 15-minute points."""
|
||||
out, t = [], start.replace(minute=0, second=0, microsecond=0)
|
||||
for _ in range(48 * 4):
|
||||
h = brussels(t).hour + brussels(t).minute / 60
|
||||
p = 0.22 + 0.06 * math.cos((h - 19) / 24 * 2 * math.pi)
|
||||
out.append(PricePoint(start=brussels(t), eur_per_kwh=round(p, 4),
|
||||
provisional=False))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
def sensor(kwh, at):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def forecast_mode(mode, t, now):
|
||||
"""What the two forecast sensors read: 'fresh', 'stale', 'gone', or a
|
||||
function of the simulated seconds that returns one of those."""
|
||||
if callable(mode):
|
||||
mode = mode(t)
|
||||
if mode == "gone":
|
||||
return {"state": "unavailable", "attributes": {}}
|
||||
at = now if mode == "fresh" else now - timedelta(hours=5)
|
||||
return at
|
||||
|
||||
|
||||
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
house_w=300.0, soc0=None):
|
||||
"""-> (controller, rows). `points`: a feed-shaped plan injected every
|
||||
cycle (no task); else the static profile via the real tariff task."""
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
rows = []
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
now = NIGHT + timedelta(seconds=self.t)
|
||||
if fc is None:
|
||||
return None
|
||||
at = forecast_mode(fc, self.t, now)
|
||||
if isinstance(at, dict):
|
||||
return at
|
||||
return sensor(0.0 if e == FC_TODAY else 12.0, at)
|
||||
|
||||
async def go():
|
||||
hass = H(("a", "b"), lambda t: house_w, soc0 or {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="t24w-")
|
||||
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
clock = [NIGHT]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
try:
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = NIGHT + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if pts is not None:
|
||||
c.tariff_plan = TariffPlan(
|
||||
PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
c.tariff_at = now
|
||||
elif t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, now=now, fc_none=c.solar_fc is None,
|
||||
runs_on=None if rp_ is None else rp_.on,
|
||||
reason=None if rp_ is None else rp_.reason,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
why=None if c.mode_tick is None else c.mode_tick.why,
|
||||
charging=hass.plants["b"].p < -200.0))
|
||||
t += tick
|
||||
c.home_plan = homeview.build_plan(c, clock[0], False, True)
|
||||
c.cards = homeview.battery_rows(c, False, True)
|
||||
# Home's mirror of the gate (it reads the trade switch, so ask it
|
||||
# as a site that has it on next to the modes).
|
||||
c2 = c
|
||||
c2.o = dict(c.o, arbitrage_enabled=True)
|
||||
c.mirror, c.mirror_why = homeview.trade_verdict(
|
||||
c2, c.fresh_tariff_plan(clock[0]), clock[0])
|
||||
c.o = dict(c.o, arbitrage_enabled=o.get("arbitrage_enabled",
|
||||
False))
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
|
||||
def why_of(c, name):
|
||||
card = first((b for b in c.cards if b["name"] == name), f"card {name}")
|
||||
return card["why_key"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the rule")
|
||||
NW = frozenset({Uncertainty.NO_WEATHER})
|
||||
check("modes on with a forecast read: no doubt", arbitrage.weather_uncertainty(
|
||||
True, object()) == frozenset())
|
||||
check("modes on with no forecast (None): the doubt", arbitrage.weather_uncertainty(
|
||||
True, None) == NW)
|
||||
check("modes off, forecast or not: the doubt, as before",
|
||||
arbitrage.weather_uncertainty(False, object()) == NW
|
||||
and arbitrage.weather_uncertainty(False, None) == NW)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. day/night 0.34 / 0.26 (gap 0.08), night period")
|
||||
c1, r1 = drive(opts(0.07), fc="fresh")
|
||||
l1 = nth(r1, -1, "last cycle")
|
||||
print(f" forecast read, cost 0.07: mode_runs on={l1['runs_on']} "
|
||||
f"reason={l1['reason']}; B charging {sum(r['charging'] for r in r1)} of "
|
||||
f"{len(r1)}; Why A {why_of(c1, A)} B {why_of(c1, B)}")
|
||||
check("with a forecast at cost 0.07 the cheap run is planned (needed 0.07 < "
|
||||
"0.08) and the Cheap hours battery charges in the night period",
|
||||
present(l1) and not l1["fc_none"] and l1["runs_on"] is True
|
||||
and sum(r["charging"] for r in r1) > len(r1) // 2
|
||||
and l1["roles"].get(B) == modetrade.CHARGE)
|
||||
for label, mode in (("no forecast sensor reads", "gone"),
|
||||
("a stale forecast", "stale")):
|
||||
c, rr = drive(opts(0.07), fc=mode)
|
||||
ll = nth(rr, -1, "last cycle")
|
||||
print(f" {label}, cost 0.07: on={ll['runs_on']} reason={ll['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rr)}; Why A "
|
||||
f"{why_of(c, A)} B {why_of(c, B)}")
|
||||
check(f"{label}: the same cost needs 0.105 > 0.08, so not_worth and "
|
||||
f"'Prices differ too little', no grid charge",
|
||||
present(ll) and ll["fc_none"] and ll["runs_on"] is False
|
||||
and ll["reason"] == "not_worth"
|
||||
and why_of(c, A) == "no_spread" and why_of(c, B) == "no_spread"
|
||||
and not any(r["charging"] for r in rr))
|
||||
c0, r0 = drive(opts(0.07, sensors=False), fc=None)
|
||||
check("no forecast sensor configured at all: not_worth too (as 0.19.0)",
|
||||
present(nth(r0, -1)) and r0[-1]["runs_on"] is False)
|
||||
c2, r2 = drive(opts(0.10), fc="fresh")
|
||||
check("at the shipped cost 0.10 even a forecast does not make 0.08 enough "
|
||||
"(needs 0.10): still not_worth, honestly",
|
||||
present(nth(r2, -1)) and r2[-1]["runs_on"] is False
|
||||
and r2[-1]["reason"] == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. a dynamic day, gap 0.12, cost 0.10")
|
||||
pts = dynamic_points(NIGHT - timedelta(hours=1))
|
||||
cd, rd = drive(opts(0.10), fc="fresh", points=pts)
|
||||
cn, rn = drive(opts(0.10), fc="gone", points=pts)
|
||||
_ld, _ln = nth(rd, -1, "last cycle"), nth(rn, -1, "last cycle")
|
||||
print(f" with forecast: on={_ld['runs_on']} reason={_ld['reason']}; "
|
||||
f"without: on={_ln['runs_on']} reason={_ln['reason']}")
|
||||
check("with a forecast the run plan is on (needs 0.10 < 0.12)",
|
||||
present(nth(rd, -1)) and rd[-1]["runs_on"] is True
|
||||
and why_of(cd, A) != "no_spread")
|
||||
check("without one it is not_worth (needs 0.15 > 0.12) and says so",
|
||||
present(nth(rn, -1)) and rn[-1]["runs_on"] is False
|
||||
and rn[-1]["reason"] == "not_worth"
|
||||
and why_of(cn, B) == "no_spread")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the forecast going and coming mid-run")
|
||||
CUT = 600.0 # 10 min in
|
||||
|
||||
|
||||
def going(t):
|
||||
return "fresh" if t < CUT else "gone"
|
||||
|
||||
|
||||
def coming(t):
|
||||
return "gone" if t < CUT else "fresh"
|
||||
|
||||
|
||||
cg, rg = drive(opts(0.07), fc=going, minutes=25)
|
||||
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
|
||||
|
||||
|
||||
def flips(rows):
|
||||
"""Cycles where the forecast read changed, and whether the plan and the
|
||||
charge gate changed in that very cycle."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i]["fc_none"] != rows[i - 1]["fc_none"]:
|
||||
out.append((i, rows[i]["t"], rows[i]["runs_on"],
|
||||
rows[i]["charging"], rows[i - 1]["charging"]))
|
||||
return out
|
||||
|
||||
|
||||
fg, fcm = flips(rg), flips(rc)
|
||||
print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}")
|
||||
check("going: the plan is on with the forecast and not_worth the cycle the "
|
||||
"read is lost (within one forecast read, 60 s, of the sensor change)",
|
||||
len(fg) == 1 and fg[0][1] - CUT <= 60.0 and fg[0][2] is False
|
||||
and all(r["runs_on"] is True for r in rg if not r["fc_none"])
|
||||
and all(r["runs_on"] is False for r in rg if r["fc_none"]))
|
||||
check("...and the grid charge stops with it: no charging after the cycle "
|
||||
"that lost the forecast, plus the 15 s the inverter ramp takes",
|
||||
len(fg) == 1 and not any(
|
||||
r["charging"] for r in rg if r["t"] >= fg[0][1] + 40.0))
|
||||
check("coming: not_worth until the read is back, then on in the same cycle, "
|
||||
"and the battery charges",
|
||||
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True
|
||||
and all(r["runs_on"] is False for r in rc if r["fc_none"])
|
||||
and all(r["runs_on"] is True for r in rc if not r["fc_none"])
|
||||
and any(r["charging"] for r in rc if r["t"] >= fcm[0][1] + 40.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. Home agrees with control")
|
||||
CASES = (("forecast, cost 0.07", c1, r1, True),
|
||||
("no forecast, cost 0.07", *drive(opts(0.07), fc="gone"), False),
|
||||
("dynamic, forecast", cd, rd, True),
|
||||
("dynamic, no forecast", cn, rn, False))
|
||||
for label, cx, rx, on in CASES:
|
||||
last = nth(rx, -1, "last cycle")
|
||||
hp = cx.home_plan
|
||||
mirror_ok = cx.mirror is not None
|
||||
print(f" {label}: control on={last['runs_on']}; Home mirror "
|
||||
f"{'allows' if mirror_ok else 'refuses'} ({cx.mirror_why}); "
|
||||
f"lines {hp['lines'][:1]}; Why B {why_of(cx, B)}")
|
||||
check(f"{label}: Home's mirror of the gate allows exactly when control "
|
||||
f"plans", present(last) and mirror_ok is on
|
||||
and last["runs_on"] is on)
|
||||
check(f"{label}: the Batteries screen and the Home line say the gap is "
|
||||
f"too small exactly when control does",
|
||||
(why_of(cx, B) == "no_spread") is (not on)
|
||||
and any("price gap is too small" in ln for ln in hp["lines"])
|
||||
is (not on))
|
||||
if label.startswith("dynamic"):
|
||||
continue # its cheap run is tomorrow morning: not on today's chart
|
||||
check(f"{label}: Home draws a grid charge block exactly when control "
|
||||
f"charges", any(r["kind"] == "grid" for r in hp["runs"]) is on)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Runnable check for UX-53. `python3 test_ux53.py`
|
||||
|
||||
Home draws a grid charge only where the add-on will take it.
|
||||
|
||||
1. Modes on (F-1): inside a cheap run that is under way, the trade gate
|
||||
(`TariffPlan.arbitrage()`, as `Controller._mode_roles()` asks it) refuses
|
||||
with `not_worth`. The control cycle files that as `Controller.mode_gate`,
|
||||
and Home draws no charge, and marks no cheap quarter, from then on. With
|
||||
the gate open the charge is drawn as before. The real `_mode_roles()` runs
|
||||
at each instant, so the page is graded against the decision it makes.
|
||||
2. Modes off (F-2): the verdict's charge comes after its last dear period.
|
||||
Home asks the same gate at the charge's own start (`homeview.leg_taken()`):
|
||||
a flat remainder is refused there, so no charge is drawn and no quarter is
|
||||
marked cheap. A charge the gate allows at its start is drawn as before, and
|
||||
so is the sale.
|
||||
3. Wording, English and Dutch: "lowest price left", and "a fully fixed
|
||||
product" only for a real fixed product.
|
||||
4. Day/night and price-off pages are the same as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from app import arbitrage, homeview, modetrade, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.10,
|
||||
"inverters": [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 2500, "capacity_kwh": 2.0},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b",
|
||||
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
||||
"max_w": 2500, "capacity_kwh": 2.0}]}
|
||||
|
||||
# Tuesday 27 October 2026 in Brussels (UTC+1 since 25 October).
|
||||
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def at(h, m=0, s=0):
|
||||
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
|
||||
|
||||
|
||||
def quarters(price_of, days=2, product=PriceProduct.DAY_AHEAD):
|
||||
"""48 hours of 15-minute prices; `price_of(local_hour_float)` -> EUR/kWh."""
|
||||
pts = []
|
||||
for i in range(96 * days):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), product,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def make(opts, plan, now, *, modes=None, soc=(50.0, 50.0)):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 500.0
|
||||
c.tariff_plan, c.tariff_at = plan, now
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
c.arb_headroom_w = 3000.0
|
||||
if modes:
|
||||
for inv in c.inverters:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes
|
||||
return c
|
||||
|
||||
|
||||
def build(c, now):
|
||||
return homeview.build(c, level="ok", checks=[], now=now)["plan"]
|
||||
|
||||
|
||||
def kinds_at(plan_json, label):
|
||||
return [b["kind"] for b in plan_json["prices"] if b["label"] == label]
|
||||
|
||||
|
||||
def grid_blocks(plan_json):
|
||||
return [r for r in plan_json["runs"] if r["kind"] == "grid"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. modes on: the gate refuses inside a cheap run (F-1)")
|
||||
# All-in prices: 0.10 everywhere, 20:00 0.01, 20:15 0.09, 21:00-22:00 0.22. The
|
||||
# cheap run is 20:00-20:30. At 20:00 the spread is 0.21. From 20:15 the quarters
|
||||
# ahead are 0.09 to 0.22: 0.13 does not clear the 0.15 this cycle needs (0.10
|
||||
# widened by 1.5 for the missing weather forecast).
|
||||
def f1_price(h):
|
||||
if h == 20.0:
|
||||
return 0.01
|
||||
if h == 20.25:
|
||||
return 0.09
|
||||
if 21.0 <= h < 22.0:
|
||||
return 0.22
|
||||
return 0.10
|
||||
|
||||
|
||||
F1_PLAN = quarters(f1_price, days=1)
|
||||
c1 = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(19, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
|
||||
|
||||
def f1(now):
|
||||
"""One real control tick of `_mode_roles()` at `now`, then Home."""
|
||||
c1.tariff_at = now
|
||||
tick = c1._mode_roles(now, None)
|
||||
return tick, build(c1, now)
|
||||
|
||||
|
||||
t_pre, p_pre = f1(at(19, 0, 5)) # builds the run plan
|
||||
t_open, p_open = f1(at(20, 0, 5))
|
||||
print(f" 20:00:05 roles {t_open.roles}, action {t_open.action}, "
|
||||
f"gate {c1.mode_gate}, runs {c1.mode_runs.cheap}")
|
||||
check("gate open (20:00): the control cycle charges, mode_gate is None, and "
|
||||
"Home draws the charge on both batteries, 20:00 to 20:30",
|
||||
t_open.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and {r["battery"] for r in grid_blocks(p_open)} == {0, 1}
|
||||
and all(r["from"] == "20:00" and r["to"] == "20:30"
|
||||
for r in grid_blocks(p_open)))
|
||||
check("...and marks the run's two quarters cheap on the chart",
|
||||
kinds_at(p_open, "20:00") == ["cheap"]
|
||||
and kinds_at(p_open, "20:15") == ["cheap"])
|
||||
t_mid, p_mid = f1(at(20, 7, 30))
|
||||
check("gate still open at 20:07: the charge goes on and is still drawn",
|
||||
t_mid.action == modetrade.CHARGE and c1.mode_gate is None
|
||||
and len(grid_blocks(p_mid)) == 2)
|
||||
c1.inverters[0].soc = c1.inverters[1].soc = 52.0
|
||||
t_ref, p_ref = f1(at(20, 17, 52))
|
||||
print(f" 20:17:52 roles {t_ref.roles}, action {t_ref.action}, why "
|
||||
f"{t_ref.why}, gate {c1.mode_gate}, run {c1.mode_runs.cheap}")
|
||||
check("the run is still kept (20:00-20:30) and the gate refuses with "
|
||||
"not_worth at 20:17: no charge, why not_worth",
|
||||
c1.mode_runs.cheap.holds(at(20, 17, 52)) and c1.mode_gate == "not_worth"
|
||||
and t_ref.action is None and t_ref.why == "not_worth"
|
||||
and all(r != modetrade.CHARGE for r in t_ref.roles.values()))
|
||||
check("Home draws no charge block for the refused part, on any battery",
|
||||
grid_blocks(p_ref) == [] and not any(
|
||||
r["what"].startswith("Charges") for r in p_ref["runs"]))
|
||||
check("...marks no quarter from 20:15 cheap, keeps the 20:00 quarter it "
|
||||
"did charge in, and keeps the give run marked dear",
|
||||
kinds_at(p_ref, "20:00") == ["cheap"]
|
||||
and kinds_at(p_ref, "20:15") == ["mid"]
|
||||
and kinds_at(p_ref, "20:30") == ["mid"]
|
||||
and kinds_at(p_ref, "21:00") == ["dear"])
|
||||
saved = c1.mode_gate
|
||||
c1.mode_gate = None # what Home did before UX-53
|
||||
p_old = build(c1, at(20, 17, 52))
|
||||
c1.mode_gate = saved
|
||||
check("the same moment without the gate record draws the charge again (the "
|
||||
"defect, shown): so the fixture is the F-1 case",
|
||||
len(grid_blocks(p_old)) == 2 and kinds_at(p_old, "20:15") == ["cheap"])
|
||||
row = homeview.battery_rows(c1, False, True)[0]
|
||||
check("the battery card says what Home now says: it rests (role hold), and "
|
||||
"does not say it charges",
|
||||
t_ref.roles["inverter-a"] == modetrade.HOLD
|
||||
and row["why_key"] in ("saves", "saves_later"))
|
||||
t_n, p_n = f1(at(20, 31, 0))
|
||||
check("after the run: nothing is drawn, as before",
|
||||
grid_blocks(p_n) == [] and c1.mode_gate == "not_worth")
|
||||
|
||||
# Other refusals of the gate are followed the same way: no charge is drawn
|
||||
# while the control cycle's gate says no.
|
||||
c1b = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1b.tariff_at = at(20, 0, 5)
|
||||
c1b.peak_guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=3600.0)
|
||||
tb = c1b._mode_roles(at(20, 0, 5), None)
|
||||
check("the peak guard holding refuses the charge (gate 'peak'): the cycle "
|
||||
"does not charge and Home draws no charge",
|
||||
c1b.mode_gate == "peak" and tb.action is None
|
||||
and grid_blocks(build(c1b, at(20, 0, 5))) == [])
|
||||
c1c = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1c.peak_guard = None
|
||||
tc = c1c._mode_roles(at(20, 0, 5), None)
|
||||
check("a peak guard that measures nothing refuses it too (gate "
|
||||
"'not_covered'): no charge, none drawn",
|
||||
c1c.mode_gate == "not_covered" and tc.action is None
|
||||
and grid_blocks(build(c1c, at(20, 0, 5))) == [])
|
||||
c1d = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
||||
modes=packmode.CHEAP_HOURS)
|
||||
c1d.arb_headroom_w = None
|
||||
td = c1d._mode_roles(at(20, 0, 5), None)
|
||||
check("a refusal that is not the gate (no measured headroom yet) is not the "
|
||||
"gate's: mode_gate stays None and the charge is still drawn",
|
||||
c1d.mode_gate is None and td.why == "headroom"
|
||||
and len(grid_blocks(build(c1d, at(20, 0, 5)))) == 2)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. modes off: a charge after the last dear period (F-2)")
|
||||
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def decide_at(c, now, soc=50.0):
|
||||
"""What the control cycle does at `now` on the plan (modes off)."""
|
||||
return arbitrage.decide(
|
||||
c.tariff_plan, now, peak_guard_active=False, charge_headroom_w=5000.0,
|
||||
soc=soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=NO_WEATHER, charge_w=2500.0, discharge_w=2500.0,
|
||||
soc_floor_pct=10.0, soc_ceiling_pct=90.0)
|
||||
|
||||
|
||||
OFF = dict(BASE)
|
||||
|
||||
|
||||
def f2_price(h):
|
||||
return 0.54 if 21.5 <= h < 21.75 else 0.24
|
||||
|
||||
|
||||
F2_PLAN = quarters(f2_price, days=1)
|
||||
c2 = make(OFF, F2_PLAN, at(21, 36, 0))
|
||||
p2 = build(c2, at(21, 36, 0))
|
||||
g2 = [r for r in p2["runs"] if r["kind"] == "give"]
|
||||
v2, _w = homeview.trade_verdict(c2, F2_PLAN, at(21, 36, 0))
|
||||
print(f" 21:36 verdict: cheapest {homeview.hhmm(v2.cheapest.start)}, "
|
||||
f"dearest {homeview.hhmm(v2.dearest.start)}")
|
||||
check("the fixture: at 21:36 the verdict's cheapest quarter (21:45) comes "
|
||||
"after its dearest (21:30, in progress), and the rest of the day is flat",
|
||||
homeview.hhmm(v2.cheapest.start) == "21:45"
|
||||
and homeview.hhmm(v2.dearest.start) == "21:30")
|
||||
check("the add-on sells in 21:30, and at 21:45 it does nothing: idle, the "
|
||||
"tariff is flat",
|
||||
decide_at(c2, at(21, 30, 5)).action == arbitrage.DISCHARGE
|
||||
and decide_at(c2, at(21, 45, 5)).action is None
|
||||
and "the tariff is flat" in decide_at(c2, at(21, 45, 5)).reason)
|
||||
check("Home draws the sale in progress and NO charge at 21:45",
|
||||
len(g2) == 2 and all(r["from"] == "21:30" for r in g2)
|
||||
and grid_blocks(p2) == [])
|
||||
check("...and marks 21:30 dear and 21:45 not cheap",
|
||||
kinds_at(p2, "21:30") == ["dear"] and kinds_at(p2, "21:45") == ["mid"])
|
||||
real = homeview.leg_taken
|
||||
homeview.leg_taken = lambda *a, **k: True # Home before UX-53
|
||||
p2_old = build(c2, at(21, 36, 0))
|
||||
homeview.leg_taken = real
|
||||
check("without the question to the gate the page draws the 21:45 charge "
|
||||
"(the defect, shown): so the fixture is the F-2 case",
|
||||
[r["from"] for r in grid_blocks(p2_old)] == ["21:45", "21:45"]
|
||||
and kinds_at(p2_old, "21:45") == ["cheap"])
|
||||
|
||||
# A charge the gate allows at its own start is still drawn, with the sale.
|
||||
def q_price(h):
|
||||
return {20.0: 0.10, 20.5: 0.12, 21.0: 0.59, 21.5: 0.54}.get(h, 0.24)
|
||||
|
||||
|
||||
Q_PLAN = quarters(q_price, days=1)
|
||||
c3 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
p3 = build(c3, at(20, 20, 0))
|
||||
v3, _w = homeview.trade_verdict(c3, Q_PLAN, at(20, 20, 0))
|
||||
check("gate allows (20:20, charge 20:30, sale 21:00): both are drawn, and "
|
||||
"the add-on does charge at 20:30 and sell at 21:00",
|
||||
[r["from"] for r in grid_blocks(p3)] == ["20:30", "20:30"]
|
||||
and [r["from"] for r in p3["runs"] if r["kind"] == "give"] == ["21:00"] * 2
|
||||
and decide_at(c3, at(20, 30, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(c3, at(21, 0, 5)).action == arbitrage.DISCHARGE
|
||||
and kinds_at(p3, "20:30") == ["cheap"] and kinds_at(p3, "21:00") == ["dear"])
|
||||
check("...and the charge in progress at 20:30 is drawn too",
|
||||
len(grid_blocks(build(c3, at(20, 35, 0)))) == 2)
|
||||
|
||||
# Every probe on a day-ahead day: the charge is drawn exactly where the add-on
|
||||
# will take it. "Takes it" is the control cycle's own `decide()` at the start
|
||||
# of the charge's period, on the same plan.
|
||||
def sweep(plan, opts, lo, hi, step=15):
|
||||
rows = []
|
||||
for m in range(lo, hi, step):
|
||||
now = at(0, 0, 0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
rows.append((now, None, None, None))
|
||||
continue
|
||||
legs = homeview.trade_legs(c, plan, v, now)
|
||||
drawn = {k: homeview.hhmm(pt.start) for k, pt in legs}
|
||||
rows.append((now, v, drawn, [k for k, _pt in legs]))
|
||||
return rows
|
||||
|
||||
|
||||
def takes(c, plan, kind, pt, now):
|
||||
"""True when decide() acts in that leg's first moment (or now, when it
|
||||
is already under way)."""
|
||||
t = max(now, pt.start + timedelta(seconds=5))
|
||||
d = decide_at(c, t)
|
||||
return d.action == (arbitrage.CHARGE if kind == "grid"
|
||||
else arbitrage.DISCHARGE)
|
||||
|
||||
|
||||
bad, nlegs, nsuppressed = [], 0, 0
|
||||
for now, v, drawn, kinds in sweep(F2_PLAN, OFF, 0, 24 * 60):
|
||||
if v is None:
|
||||
continue
|
||||
c = make(OFF, F2_PLAN, now)
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
nlegs += 1
|
||||
shown = kind in kinds
|
||||
if not shown:
|
||||
nsuppressed += 1
|
||||
if shown != takes(c, F2_PLAN, kind, pt, now):
|
||||
bad.append((homeview.hhmm(now), kind, shown))
|
||||
print(f" {nlegs} verdict legs over a day, {nsuppressed} not drawn, "
|
||||
f"{len(bad)} where Home and decide() disagree {bad[:3]}")
|
||||
check("over a whole day of 15-minute probes, a leg is drawn exactly when "
|
||||
"decide() acts on it at its start; and some are not drawn",
|
||||
not bad and nsuppressed > 0)
|
||||
|
||||
# The same, on 40 random days of 15-minute prices (rounded to a cent, so there
|
||||
# are ties), today only, every quarter probed: Home and decide() agree on every
|
||||
# leg, and the page does drop some.
|
||||
import random # noqa: E402
|
||||
rnd = random.Random(53)
|
||||
n_leg = n_drop = 0
|
||||
disagree = []
|
||||
for day in range(40):
|
||||
shape = [round(rnd.choice((0.10, 0.18, 0.24, 0.24, 0.30, 0.45, 0.60))
|
||||
+ rnd.randrange(0, 3) * 0.01, 2) for _ in range(96)]
|
||||
if day % 2:
|
||||
shape = [shape[i // 4 * 4] for i in range(96)] # hour-constant
|
||||
plan = quarters(lambda h, sh=shape: sh[int(h * 4)], days=1)
|
||||
c = make(OFF, plan, at(0, 0, 0))
|
||||
for q in range(96):
|
||||
now = at(0, 0, 0) + timedelta(minutes=15 * q, seconds=7)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
continue
|
||||
kinds = [k for k, _pt in homeview.trade_legs(c, plan, v, now)]
|
||||
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
||||
n_leg += 1
|
||||
shown = kind in kinds
|
||||
n_drop += not shown
|
||||
if shown != takes(c, plan, kind, pt, now):
|
||||
disagree.append((day, homeview.hhmm(now), kind, shown))
|
||||
print(f" 40 random days: {n_leg} verdict legs, {n_drop} not drawn, "
|
||||
f"{len(disagree)} disagreements with decide() {disagree[:3]}")
|
||||
check("40 random 15-minute days, every quarter: a leg is drawn exactly "
|
||||
"when decide() acts on it at its start",
|
||||
not disagree and n_leg > 3000 and n_drop > 0)
|
||||
|
||||
# A charge tomorrow: announced only when the add-on will take it. Today's
|
||||
# dear period is 21:00 (0.50, then 0.30). Tomorrow opens with a dear hour
|
||||
# (0.45) and the cheapest quarter comes right after it, at 01:00 (0.24).
|
||||
def two_days(tomorrow):
|
||||
pts = []
|
||||
for i in range(96 * 2):
|
||||
t = DAY0 + timedelta(minutes=15 * i)
|
||||
h = (i % 96) / 4.0
|
||||
price = (0.50 if h == 21.0 else 0.30) if i < 96 else tomorrow(h)
|
||||
pts.append(PricePoint(brussels(t), price, False))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", DAY0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def tomorrow_lines(plan, now):
|
||||
c = make(OFF, plan, now)
|
||||
return [x for x in build(c, now)["lines"] if x.startswith("Tomorrow")]
|
||||
|
||||
|
||||
T_FLAT = two_days(lambda h: 0.45 if h < 1.0 else 0.24)
|
||||
T_WITH = two_days(lambda h: 0.45 if h < 1.0 else (0.50 if h == 18.0 else 0.24))
|
||||
l_with = tomorrow_lines(T_WITH, at(21, 5, 0))
|
||||
l_flat = tomorrow_lines(T_FLAT, at(21, 5, 0))
|
||||
print(f" tomorrow lines: with a later dear period {l_with}; without {l_flat}")
|
||||
check("tomorrow's charge (01:00) is announced when a dearer period follows "
|
||||
"it, and not when the rest of tomorrow is flat",
|
||||
len(l_with) == 1 and "01:00" in l_with[0] and l_flat == [])
|
||||
check("...and the add-on charges at 01:00 on the first and does not on the "
|
||||
"second",
|
||||
decide_at(make(OFF, T_WITH, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action == arbitrage.CHARGE
|
||||
and decide_at(make(OFF, T_FLAT, at(21, 5, 0)),
|
||||
at(25, 0, 5)).action is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. wording, English and Dutch")
|
||||
c6 = make(OFF, Q_PLAN, at(20, 20, 0))
|
||||
en = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="en")["plan"]
|
||||
nl = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
||||
lang="nl")["plan"]
|
||||
gw_en = [r["why"] for r in en["runs"] if r["kind"] == "grid"]
|
||||
gw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "grid"]
|
||||
dw_en = [r["why"] for r in en["runs"] if r["kind"] == "give"]
|
||||
dw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "give"]
|
||||
print(f" {gw_en[:1]} {gw_nl[:1]} {dw_en[:1]} {dw_nl[:1]}")
|
||||
check("the charge says 'lowest price left', English and Dutch",
|
||||
gw_en and set(gw_en) == {"Lowest price left, \u20ac0.12 per kWh."}
|
||||
and set(gw_nl) == {"Laagste prijs die nog over is, \u20ac0,12 per kWh."})
|
||||
check("...and the sale 'highest price left'",
|
||||
set(dw_en) == {"Highest price left, \u20ac0.59 per kWh."}
|
||||
and set(dw_nl) == {"Hoogste prijs die nog over is, \u20ac0,59 per kWh."})
|
||||
check("...and no 'published' is left in the run words of either language",
|
||||
all("ublished" not in str(homeview.uitext.words(l)["home_words"]["run"])
|
||||
and "gepubliceerd" not in str(
|
||||
homeview.uitext.words(l)["home_words"]["run"])
|
||||
for l in ("en", "nl")))
|
||||
check("the short words fit the plan-line rule: one sentence of at most 12 "
|
||||
"words",
|
||||
all(len(x.split()) <= 12 for x in gw_en + gw_nl + dw_en + dw_nl))
|
||||
|
||||
NOW6 = at(21, 45, 5)
|
||||
FLAT_DYN = quarters(lambda h: 0.24, days=1)
|
||||
r_dyn = FLAT_DYN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=NOW6).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
FIXED = {"periods": [{"name": "all", "from": "00:00", "to": "24:00",
|
||||
"days": "all", "eur_per_kwh": 0.30}]}
|
||||
FIXED_PLAN = static_plan({"static_profile": json.dumps(FIXED)}, at(0), at(36),
|
||||
now=at(0))
|
||||
r_fix = FIXED_PLAN.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.10, uncertainty=NO_WEATHER,
|
||||
now=NOW6).match(allowed=lambda v: None,
|
||||
refused=lambda v: v.reason)
|
||||
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.34},
|
||||
{"name": "night", "from": "22:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.26}]}
|
||||
DN_FLAT = static_plan({"static_profile": json.dumps(DN)}, at(23, 0), at(30),
|
||||
now=at(23, 0)) # 23:00 to 06:00: night only
|
||||
r_dn = DN_FLAT.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER, now=at(23, 10)).match(
|
||||
allowed=lambda v: None, refused=lambda v: v.reason)
|
||||
print(f" dynamic: {r_dyn}\n day/night: {r_dn}\n fixed: {r_fix}")
|
||||
check("a dynamic site whose remaining quarters are equal is refused as flat, "
|
||||
"and is not called a fully fixed product",
|
||||
r_dyn and r_dyn.startswith("the tariff is flat - every one of the ")
|
||||
and "fully fixed" not in r_dyn and "fixed product" not in r_dyn
|
||||
and r_dyn.endswith("arithmetically impossible"))
|
||||
check("...a day/night site whose remaining hours are all night: the same",
|
||||
r_dn and r_dn.startswith("the tariff is flat")
|
||||
and "fixed" not in r_dn)
|
||||
check("...a site with a real fixed product is still told so",
|
||||
r_fix and r_fix.endswith(
|
||||
"§9.2: a fully fixed product is self-consumption only")
|
||||
and FIXED_PLAN.product is PriceProduct.FIXED
|
||||
and DN_FLAT.product is PriceProduct.STATIC
|
||||
and FLAT_DYN.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. day/night, hourly and price-off pages")
|
||||
def old_vs_new(plan, opts, hours=24):
|
||||
"""Verdict legs the page drew before (both, when there is a verdict) and
|
||||
the ones it draws now, probed every 30 minutes."""
|
||||
same = diff = none = 0
|
||||
for m in range(0, hours * 60, 30):
|
||||
now = at(0) + timedelta(minutes=m, seconds=7)
|
||||
c = make(opts, plan, now)
|
||||
v, _w = homeview.trade_verdict(c, plan, now)
|
||||
if v is None:
|
||||
none += 1
|
||||
continue
|
||||
if len(homeview.trade_legs(c, plan, v, now)) == 2:
|
||||
same += 1
|
||||
else:
|
||||
diff += 1
|
||||
return same, diff, none
|
||||
|
||||
|
||||
DN_OPTS = dict(OFF, cycle_cost_threshold_eur=0.02,
|
||||
static_profile=json.dumps(DN))
|
||||
DN_48 = static_plan(DN_OPTS, at(0), at(48), now=at(0))
|
||||
s_dn = old_vs_new(DN_48, DN_OPTS)
|
||||
print(f" day/night, 48 h plan, 48 probes: same {s_dn[0]}, changed "
|
||||
f"{s_dn[1]}, no verdict {s_dn[2]}")
|
||||
check("day/night (a 48-hour plan, as the refresh builds it): at every probe "
|
||||
"that has a verdict both legs are drawn, as before; and there are some",
|
||||
s_dn[1] == 0 and s_dn[0] >= 20)
|
||||
HOURLY = quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22,
|
||||
.18, .16, .15, .14, .16, .19, .23, .28, .33, .36,
|
||||
.34, .29, .26, .24][int(h)], days=2)
|
||||
HR_OPTS = dict(OFF, cycle_cost_threshold_eur=0.05)
|
||||
s_hr = old_vs_new(HOURLY, HR_OPTS)
|
||||
print(f" hourly shape on a 48 h plan: same {s_hr[0]}, changed {s_hr[1]}, "
|
||||
f"no verdict {s_hr[2]}")
|
||||
check("an hourly-shaped day-ahead feed with tomorrow known: both legs are "
|
||||
"drawn at every probe with a verdict, as before",
|
||||
s_hr[1] == 0 and s_hr[0] >= 20)
|
||||
s_one = old_vs_new(quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27,
|
||||
.26, .22, .18, .16, .15, .14, .16, .19,
|
||||
.23, .28, .33, .36, .34, .29, .26,
|
||||
.24][int(h)], days=1), HR_OPTS)
|
||||
print(f" the same with today only: same {s_one[0]}, changed {s_one[1]}, "
|
||||
f"no verdict {s_one[2]}")
|
||||
check("...and with today only, the probes that change are the ones where "
|
||||
"the cheapest quarter left is after the last dear one (F-2)",
|
||||
s_one[1] > 0 and s_one[0] > 0)
|
||||
c7 = make(dict(OFF, arbitrage_enabled=False), DN_48, at(12, 0, 0))
|
||||
p7 = build(c7, at(12, 0, 0))
|
||||
check("price trading off (a day/night site, modes off): no charge or sale "
|
||||
"is drawn and no bar is marked, as before",
|
||||
[r for r in p7["runs"] if r["kind"] in ("grid", "give")] == []
|
||||
and {b["kind"] for b in p7["prices"]} == {"mid"})
|
||||
c8 = make(dict(OFF, arbitrage_enabled=False, battery_modes_enabled=True),
|
||||
DN_48, at(12, 0, 0), modes=packmode.SOLAR_FIRST)
|
||||
c8._mode_roles(at(12, 0, 0), None)
|
||||
p8 = build(c8, at(12, 0, 0))
|
||||
check("modes on, every battery Solar first (the owner's setting): no grid "
|
||||
"charge is drawn, and the gate record changes nothing",
|
||||
grid_blocks(p8) == [] and {b["kind"] for b in p8["prices"]} == {"mid"})
|
||||
c9 = make(dict(OFF, battery_modes_enabled=True), DN_48, at(12, 0, 0),
|
||||
modes=packmode.SOLAR_FIRST)
|
||||
check("a controller that never ran a cycle has no gate record, and the "
|
||||
"default is 'no refusal' (None)",
|
||||
getattr(c9, "mode_gate", "missing") is None)
|
||||
|
||||
print(f"fails: {len(fails)}")
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user