Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dfe9720c5 | ||
|
|
242cf434f4 | ||
|
|
514256923b | ||
|
|
b7108e552d | ||
|
|
31d09ebae2 | ||
|
|
ae9268835f | ||
|
|
f8b3e23a46 | ||
|
|
aa228062c5 |
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## 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.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because price trading changes when the battery charges and gives power; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.18.2 also
|
||||
start 0.19.0. The changes below apply with one inverter or two.
|
||||
|
||||
**Price trading skips periods that are over.** When the add-on trades on price, it now picks only from price periods that are not over yet. With battery modes off and 15-minute prices, the battery therefore trades for longer (about 1.5 to 2.5 times the minutes in a simulation of 16 real days), so it cycles more; with battery modes on, it no longer charges from the grid because of a cheap period that is over. Hourly and day/night sites change too: they no longer pause at the start of each hour.
|
||||
|
||||
Contents of this release, in merge order since 0.18.2 (`c67d0cd`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `243a756` | The docs, the add-on description and the field guide now say the add-on is wired to the inverter's EMS port, not a "meter bus". In Home Assistant the device's manufacturer text becomes "GoodWe (via RS485 EMS port)", and the device and its entities stay the same ([[DOC-8]], #201) |
|
||||
| `75f0804` | The price chart on the Home page now shows one bar for each 15-minute price, the price the add-on decides on, where it showed hour averages. Its cheap and dear colours now show where the add-on will charge or give power, instead of thirds of the price range ([[UX-45]], #202) |
|
||||
| `bee7431` | First half of the "not keeping up" fix, shipped with the next row and described there ([[SAFETY-119]], #203) |
|
||||
| `e077ae7` | The false "Battery is not keeping up" warning, and its Home Assistant notification, no longer appear when the peak guard steps in and then gives control back near the start of a 15-minute period; battery commands do not change. A real overload still warns at the start of each 15-minute period, up to about 25 s later with the default 5 s meter, but with an uneven meter of about 15 to 20 s and a small overload, it can stay quiet for up to an hour ([[SAFETY-121]], #204, with [[SAFETY-119]]) |
|
||||
| `90bc901` | On Dutch pages, the status rows, banners and settings notes now show Dutch text where they showed English, and so do the setup wizard's checks once [[UX-51]] is fixed. The settings label of the 15-minute demand forecast sensor no longer ends in "(W)", and English pages change only by that label ([[UX-46]], #205) |
|
||||
| `aa22806` | Price trading, and the check for it with battery modes on, now ignore price periods that have already ended, so the add-on no longer aims at a 15-minute period that is over and then does nothing. Hourly and day/night sites also change: there is no pause at the start of each hour, as the owner accepted ([[PRICE-02]], #206) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Home can show a grid charge that the add-on will not take: the rest of a cheap run that the trade check refuses (battery modes on), or a charge after the last dear price period (battery modes off). The battery stays idle then ([[UX-53]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
|
||||
### 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.18.2
|
||||
|
||||
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
|
||||
|
||||
@@ -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.
|
||||
@@ -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):
|
||||
@@ -306,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
|
||||
|
||||
@@ -322,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
|
||||
@@ -371,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
|
||||
@@ -381,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)
|
||||
@@ -397,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:
|
||||
@@ -608,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:
|
||||
|
||||
@@ -1001,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
|
||||
@@ -2380,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:
|
||||
|
||||
@@ -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.18.2"
|
||||
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"]
|
||||
|
||||
@@ -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