Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f095ca9268 | ||
|
|
1eb572f513 | ||
|
|
cdcbe7dbb6 | ||
|
|
665653517c | ||
|
|
7433786a37 | ||
|
|
2932f3af39 | ||
|
|
7e77c723b9 | ||
|
|
97686319f3 | ||
|
|
d424b862d8 | ||
|
|
382b0ea411 | ||
|
|
230ba0b57a | ||
|
|
d667f3f3a8 | ||
|
|
4a1f6cc2d9 | ||
|
|
793a67623c | ||
|
|
ad374ae208 | ||
|
|
ec2b0c3de4 | ||
|
|
3b7d6b7f05 | ||
|
|
1b6f16dba2 | ||
|
|
df154a26d1 | ||
|
|
21599938ea | ||
|
|
3a54b253cb | ||
|
|
8b9680fc35 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
@@ -1,5 +1,33 @@
|
||||
# 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,43 @@
|
||||
# OPT-27 - the night plan keeps the last fresh forecast
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
|
||||
|
||||
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
|
||||
before local midnight. Forecast.Solar stops updating in the evening, so at night
|
||||
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
|
||||
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
|
||||
Separately, one missed poll (`unavailable`) dropped the forecast at once.
|
||||
|
||||
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
|
||||
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
|
||||
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
|
||||
day plan, Leftover solar's levels, Home and the Forecast tab all read
|
||||
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
|
||||
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
|
||||
still keeps a run under way when a read is lost.
|
||||
|
||||
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
|
||||
sensor that is missing or stale, never one with a bad value or unit, and:
|
||||
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
|
||||
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight + 3 h
|
||||
(`KEPT_UNTIL_H`), however old;
|
||||
- the day it is for: a today figure only on that day, a tomorrow figure as
|
||||
tomorrow until midnight and, if the today sensors are still stale, as today's
|
||||
until midnight + 3 h (all sensors of the sum are needed, as for any sum);
|
||||
- until the next fresh reading, which replaces it at once.
|
||||
|
||||
So a figure is never used for another day and never older than one night.
|
||||
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
|
||||
all day is still no forecast (the night run still needs 1.5x).
|
||||
|
||||
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
|
||||
evening restart keeps it. It is written when a reading's figure changes or
|
||||
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
|
||||
|
||||
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
|
||||
in use; the forecast line then reads "Solar forecast read 17:05, used for
|
||||
tonight's plan." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
"read, kept from 17:05".
|
||||
|
||||
**Not changed.** Modes off (nothing reads the sensors). A forecast that stays
|
||||
fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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, with one exception:
|
||||
a run planned with a forecast that is under way keeps that gap to its end
|
||||
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
|
||||
or restart a charge. The wider gap applies from the next run.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -555,6 +555,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never older than that night. The Batteries screen says "Solar forecast read 17:05, used for tonight's plan." With no reading all day, the night run still needs the wider gap.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
|
||||
@@ -147,7 +147,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, price_option
|
||||
from .price import Absence, Uncertainty, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
|
||||
@@ -267,6 +267,21 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
return v
|
||||
|
||||
|
||||
def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> 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, and `held` (a run planned with a forecast is under way, see
|
||||
`Controller.weather_held()`) keeps it gone until that run ends. Everything
|
||||
else, and the modes without a forecast, keep it. One rule for the run plan,
|
||||
the charge gate and Home's mirror. See
|
||||
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
||||
"""
|
||||
if modes_on and (solar_fc is not None or held):
|
||||
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")),
|
||||
|
||||
@@ -368,7 +368,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
if fc is not None:
|
||||
if k == 0:
|
||||
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
|
||||
hv.hhmm(fc.at))
|
||||
hv.hhmm(fc.at),
|
||||
kept=getattr(fc, "kept", False))
|
||||
elif fc.tomorrow is not None:
|
||||
line = hv.day_note("tomorrow_total",
|
||||
kwh=hv._kwh(fc.tomorrow.kwh),
|
||||
|
||||
@@ -23,7 +23,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, Uncertainty, brussels, _cover_table
|
||||
from .price import Absence, brussels, _cover_table
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
@@ -279,7 +279,11 @@ 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=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
bool(getattr(c, "modes_on", False)),
|
||||
getattr(c, "solar_fc", None),
|
||||
bool(getattr(c, "weather_held",
|
||||
lambda _n: False)(now))),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
@@ -539,7 +543,8 @@ def solar_block(c, now):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
out["updated"],
|
||||
kept=getattr(fc, "kept", False))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1391,7 +1396,11 @@ def day_note(key, **values) -> str:
|
||||
return _t("day." + key, **values)
|
||||
|
||||
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
|
||||
"""The forecast line. OPT-27: a reading kept past three hours (the
|
||||
evening's, used at night) says so instead of "Updated"."""
|
||||
if kept:
|
||||
return _t("solar.kept", at=at)
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
|
||||
@@ -175,57 +175,17 @@ TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
"""Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24)
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
return (provider_configured(opts) or arbitrage.enabled(opts)
|
||||
or packmode.enabled(opts))
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
@@ -248,6 +208,9 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the figure the night plan uses.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
@@ -1001,6 +964,9 @@ class Controller:
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
# TARIFF-24: the end of the run that was under way while a forecast
|
||||
# was read; until then losing the forecast does not widen the gap.
|
||||
self._wx_free_to = 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
|
||||
@@ -1032,6 +998,8 @@ class Controller:
|
||||
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
|
||||
else solarfc.MISSING)
|
||||
self._fc_read_mono = None
|
||||
self.fc_hold = solarfc.Hold(
|
||||
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
|
||||
self.day_plan = None
|
||||
self._plan_targets = None
|
||||
self.house_profile = None
|
||||
@@ -2274,6 +2242,28 @@ class Controller:
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _wx_track(self, now) -> None:
|
||||
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
|
||||
remember when the run now under way (cheap, give or sell) ends."""
|
||||
if self.solar_fc is None:
|
||||
return
|
||||
rp = self.mode_runs
|
||||
ends = []
|
||||
if rp is not None and rp.on:
|
||||
ends = [r.end for r in (rp.cheap, rp.sell)
|
||||
if r is not None and r.holds(now)]
|
||||
g = rp.give_at(now)
|
||||
if g is not None:
|
||||
ends.append(g.end)
|
||||
self._wx_free_to = max(ends) if ends else None
|
||||
|
||||
def weather_held(self, now) -> bool:
|
||||
"""TARIFF-24: no forecast now, but a run planned with one is under
|
||||
way: it keeps its gap to its end. The wider gap applies from the next
|
||||
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
|
||||
return (self.solar_fc is None and self._wx_free_to is not None
|
||||
and now < self._wx_free_to)
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
when the prices or a battery's choice change; a run under way is
|
||||
@@ -2287,9 +2277,11 @@ class Controller:
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
held = self.weather_held(now)
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
# P4: a forecast arriving or going rebuilds the plan at once (and
|
||||
# so does the end of a run that held the gap, TARIFF-24).
|
||||
key += (self.solar_fc is None, held)
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2298,7 +2290,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2367,6 +2359,7 @@ class Controller:
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
self._wx_track(now)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
@@ -2377,7 +2370,8 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
True, self.solar_fc, self.weather_held(now)),
|
||||
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:
|
||||
@@ -3417,17 +3411,25 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
self.log_event(
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})")
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
@@ -8080,11 +8082,9 @@ 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 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.
|
||||
# ⚠️ 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.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
|
||||
@@ -28,6 +28,17 @@ A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
|
||||
night plan is not made blind. Forecast.Solar's sensors stop changing in the
|
||||
evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight + `KEPT_UNTIL_H` however old it is. Never beyond that, and never
|
||||
for another day: a today figure is only today's, a tomorrow figure is
|
||||
tomorrow's until midnight and then, until midnight + 3 h, today's.
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
@@ -50,6 +61,11 @@ FRESH_S = 3 * 3600.0
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# OPT-27: a kept reading serves the night plan from the end of the bell until
|
||||
# this many hours after local midnight (the card: "local midnight + 3 h").
|
||||
KEPT_UNTIL_H = 3.0
|
||||
SAVE_EVERY_S = 900.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
@@ -75,6 +91,10 @@ class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
|
||||
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
|
||||
# as before.
|
||||
kept: bool = False
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
@@ -234,6 +254,108 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
class Hold:
|
||||
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
|
||||
for. `read_sum()` remembers every fresh reading here and, when a sensor
|
||||
has none now, asks it (`stand_in()`).
|
||||
|
||||
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
|
||||
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
|
||||
FOR. A today figure therefore never answers for another day, and a
|
||||
tomorrow figure answers for its own day only: tomorrow until midnight,
|
||||
then today until midnight + `KEPT_UNTIL_H`. Only the newest reading of a
|
||||
key is kept, so the next fresh reading ends the hold at once.
|
||||
|
||||
`dump()` / `load()` are what the controller keeps in its store, so an
|
||||
evening restart does not lose it (`load()` drops what is bad).
|
||||
`changed` is set when `dump()` would differ."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self._r = {}
|
||||
self._saved = {} # key -> the `at` last flagged for saving
|
||||
self.changed = False
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
|
||||
key = (kind, entity, day.day0)
|
||||
old = self._r.get(key)
|
||||
if old is not None and at <= old[1]:
|
||||
return
|
||||
self._r[key] = (day, at)
|
||||
# A sensor that reports every minute with the same figure must not
|
||||
# rewrite the store every minute: the saved age may lag by
|
||||
# `SAVE_EVERY_S` (the bounds are hours).
|
||||
mark = self._saved.get(key)
|
||||
if old is None or old[0].kwh != day.kwh or mark is None \
|
||||
or (at - mark).total_seconds() >= SAVE_EVERY_S:
|
||||
self._saved[key] = at
|
||||
self.changed = True
|
||||
|
||||
def forget_before(self, day0: datetime) -> None:
|
||||
"""Drop what is for a day before `day0` (the local midnight now)."""
|
||||
gone = [k for k in self._r if k[2] < day0]
|
||||
for k in gone:
|
||||
del self._r[k]
|
||||
self._saved.pop(k, None)
|
||||
if gone:
|
||||
self.changed = True
|
||||
|
||||
def stand_in(self, kind, entity, day0, now, *, as_today=False):
|
||||
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
or within `KEPT_UNTIL_H` after it. `kept` is true when (b) was needed,
|
||||
or `as_today` (a tomorrow figure standing in for today, after
|
||||
midnight)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
return None
|
||||
day, at = rec
|
||||
if (now - at).total_seconds() <= FRESH_S and not as_today:
|
||||
return day, at, False
|
||||
# Both sides: a tomorrow sensor is asked with day0 = the next
|
||||
# midnight, which is in the future all day (round 2).
|
||||
after_midnight = day0 <= now < day0 + timedelta(hours=KEPT_UNTIL_H)
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < day0 + timedelta(
|
||||
days=1)
|
||||
if as_today:
|
||||
return (day, at, True) if after_midnight else None
|
||||
return (day, at, True) if (evening or after_midnight) else None
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"v": 1, "r": [
|
||||
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
|
||||
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
|
||||
for (kind, ent, _d0), (d, at) in sorted(
|
||||
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
|
||||
kv[0][2]))]}
|
||||
|
||||
def load(self, data: dict) -> None:
|
||||
rows = data.get("r") if isinstance(data, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return
|
||||
for row in rows:
|
||||
try:
|
||||
kind, ent, d0, kwh, at, per = row
|
||||
day0, at = _parse_time(d0), _parse_time(at)
|
||||
kwh = float(kwh)
|
||||
periods = tuple((_parse_time(s), _parse_time(e), float(k))
|
||||
for s, e, k in per)
|
||||
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
|
||||
and day0 is not None and at is not None
|
||||
and math.isfinite(kwh) and kwh >= 0
|
||||
and all(s is not None and e is not None and e > s
|
||||
and math.isfinite(k) and k >= 0
|
||||
for s, e, k in periods))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ok:
|
||||
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
|
||||
self._saved[(kind, ent, day0)] = at
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
@@ -270,14 +392,14 @@ def _today(state, day0, now):
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
return None, None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
return None, None, STALE
|
||||
return t, t_at, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
@@ -293,7 +415,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
return None, why
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
@@ -309,7 +431,12 @@ def _sum(days):
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
# Why a sensor may be covered by a kept reading: it has none now, or it is
|
||||
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
|
||||
_COVERABLE = (MISSING, STALE)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
@@ -322,29 +449,67 @@ def read_sum(today, tomorrow, now: datetime):
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
`read()` gives (test_opt17.py proves it).
|
||||
|
||||
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
|
||||
member that is missing or stale is covered by its last fresh reading
|
||||
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
|
||||
that took a reading older than `FRESH_S`). After midnight, if the today
|
||||
side is still short, the tomorrow figures read before midnight are
|
||||
today's, until midnight + `KEPT_UNTIL_H`. Without a `hold` this is
|
||||
exactly as before."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
if hold is not None:
|
||||
hold.forget_before(day0)
|
||||
gaps, days, ats, why0, kept = [], [], [], None, False
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is not None and hold is not None:
|
||||
hold.remember("today", ent, d, at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("today", ent, day0, now)
|
||||
if r is not None:
|
||||
d, at, k = r
|
||||
kept = kept or k
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
tdays, tats, tkept = [], [], False
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
t, t_at, why = _tomorrow(st, day0, day1, now)
|
||||
if t is not None and hold is not None:
|
||||
hold.remember("tomorrow", ent, t, t_at)
|
||||
elif hold is not None and why in _COVERABLE:
|
||||
r = hold.stand_in("tomorrow", ent, day1, now)
|
||||
if r is not None:
|
||||
t, t_at, k = r
|
||||
tkept = tkept or k
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
tats.append(t_at)
|
||||
if why0 is not None and hold is not None and tomorrow and all(
|
||||
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
|
||||
# Past midnight the sensors may still show yesterday's "left today".
|
||||
# The figure each tomorrow sensor gave before midnight is for this
|
||||
# day; all of them are needed, as for any sum.
|
||||
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
|
||||
for ent, _st in tomorrow]
|
||||
if all(r is not None for r in subs):
|
||||
days = [r[0] for r in subs]
|
||||
ats = [r[1] for r in subs]
|
||||
gaps = [g for g in gaps if g[0] != "today"]
|
||||
why0, kept = None, True
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "kept": "Solar forecast read {at}, used for tonight's plan.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "kept": "Zonneprognose van {at}, gebruikt voor het plan van vanavond.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.0"
|
||||
version: "0.19.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -540,6 +540,12 @@ 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):
|
||||
@@ -597,7 +603,8 @@ 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})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
@@ -607,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
|
||||
for r in rows))
|
||||
check("sunny: no grid charge at all", not charge)
|
||||
|
||||
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
|
||||
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
|
||||
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
|
||||
# a planned top-up for B (Leftover solar) up to its planned level.
|
||||
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
|
||||
_first_g = nth(charge_g, 0, "a charge cycle")
|
||||
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
|
||||
- r["runs"].cheap.mean_eur_kwh for r in charge_g
|
||||
if r["runs"] is not None and r["runs"].give]
|
||||
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
|
||||
f"{min(_gaps, default=0):.3f}, B to "
|
||||
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
|
||||
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
|
||||
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
|
||||
"inside it, only B, and only up to its planned level",
|
||||
present(_first_g) and len(_gaps) == len(charge_g)
|
||||
and all(0.20 <= g < 0.30 for g in _gaps)
|
||||
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
|
||||
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
|
||||
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
|
||||
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
|
||||
check("...and the same day with no forecast read does not (it needs 0.30): "
|
||||
"the old gate, kept for a day without a forecast",
|
||||
not [r for r in run_day(SF_LS, fc=None,
|
||||
soc0={"a": 60.0, "b": 30.0})[0]
|
||||
if r["action"] == arbitrage.CHARGE
|
||||
and r["now"].astimezone(L).hour == 12])
|
||||
|
||||
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("dark", rows, c)
|
||||
@@ -623,7 +659,8 @@ 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})
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
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} %")
|
||||
@@ -635,9 +672,24 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
and top > 80.0)
|
||||
|
||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
|
||||
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
|
||||
# the next scenario is the same loss with the hold (test_opt27.py).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
def seed_profile_no_hold(c):
|
||||
seed_profile(c)
|
||||
c.fc_hold = _NoHold()
|
||||
|
||||
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
@@ -645,10 +697,20 @@ late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast unavailable mid-run, covered", rows, c)
|
||||
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
|
||||
"Leftover solar goes on charging past the point where it was lost",
|
||||
[r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
|
||||
|
||||
# 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})
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
|
||||
extra=GATE_030)
|
||||
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"]
|
||||
@@ -658,7 +720,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",))
|
||||
refuse=("b",), extra=GATE_030)
|
||||
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,548 @@
|
||||
"""Runnable check for OPT-27. `python3 test_opt27.py`
|
||||
|
||||
Forecast.Solar's sensors stop changing in the evening, so at night every
|
||||
Forecast.Solar site was "no forecast": a price mode's night cheap run needed
|
||||
1.5x the cycle cost and Leftover solar bought nothing. And one missed poll
|
||||
(`unavailable`) dropped the forecast at once. `solarfc.Hold` now keeps the last
|
||||
fresh reading of each sensor, for the day it is FOR:
|
||||
|
||||
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
|
||||
* from the end of the bell (20:00) until local midnight + 3 h however old it
|
||||
is (the night plan); never beyond, never for another day.
|
||||
|
||||
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
|
||||
|
||||
1. `solarfc` alone: the evening, the outage, the bounds, the day a figure is
|
||||
for, restart (dump/load), and no hold = as before.
|
||||
2. The real Controller on a day/night tariff (0.34/0.26, gap 0.08, cycle cost
|
||||
0.07), forecast sensors that stop updating at 17:05 local:
|
||||
a. the 22:00 night run is planned with the cycle-cost gap and charges;
|
||||
b. no forecast all day: the night run still needs 1.5x and does not charge;
|
||||
c. an evening restart keeps it (a new Controller on the saved store);
|
||||
d. a one-hour outage at 18:47 does not drop it;
|
||||
e. past midnight + 3 h the figure is not used, before it it is.
|
||||
3. The screens: the forecast line says when it was read and that it is used
|
||||
for tonight's plan (EN and NL), and Home agrees with control.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file): see the
|
||||
DECISIONS note and the dev report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
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, solarfc # noqa: E402
|
||||
from app.main import Controller # 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)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
"""Local time on 2026-11-`day` (CET) as UTC."""
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. solarfc: the hold")
|
||||
D1_READ = at(3, 17, 5) # the last fresh reading, 17:05
|
||||
EVE = at(3, 22, 0)
|
||||
|
||||
|
||||
def rs(today, tomorrow, now, hold):
|
||||
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
|
||||
|
||||
|
||||
h = solarfc.Hold()
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 17, 10), h)
|
||||
check("a fresh reading is a forecast, not kept", fc is not None
|
||||
and not fc.kept and fc.tomorrow.kwh == 12.0 and fc.today.kwh == 0.4)
|
||||
check("it is remembered (the hold is flagged for saving)", h.changed)
|
||||
fc_no, why_no, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, None)
|
||||
check("with no hold, 22:00 is no forecast (as before)",
|
||||
fc_no is None and why_no == solarfc.STALE)
|
||||
fc, why, gaps = rs(st(0.4, D1_READ), st(12.0, D1_READ), EVE, h)
|
||||
print(f" 22:00, sensors still show 17:05: {fc and fc.kept=}, {gaps=}")
|
||||
check("22:00 with the sensors stale since 17:05: the 17:05 figures are used",
|
||||
fc is not None and fc.kept and fc.tomorrow.kwh == 12.0
|
||||
and fc.at == D1_READ and gaps == ())
|
||||
check("... today has no sun left in it, tomorrow's is counted from 06:00",
|
||||
fc is not None and fc.between(EVE, at(4, 7, 0)) is not None
|
||||
and 0.0 < fc.between(EVE, at(4, 7, 0)) < 0.5
|
||||
and fc.today.kwh == 0.4)
|
||||
fc2, _w, _g = rs(GONE, GONE, EVE, h)
|
||||
check("the same when the sensors are unavailable at 22:00",
|
||||
fc2 is not None and fc2.kept and fc2.tomorrow.kwh == 12.0)
|
||||
# A new fresh reading replaces the old one at once.
|
||||
fc3, _w, _g = rs(st(0.0, at(3, 22, 20)), st(14.5, at(3, 22, 20)),
|
||||
at(3, 22, 30), h)
|
||||
check("a newer fresh reading is used and is not 'kept'",
|
||||
fc3 is not None and not fc3.kept and fc3.tomorrow.kwh == 14.5)
|
||||
fc3b, _w, _g = rs(st(0.4, D1_READ), st(12.0, D1_READ), at(3, 23, 0), h)
|
||||
check("... and later the newest reading is the one kept, not the old one",
|
||||
fc3b is not None and fc3b.tomorrow.kwh == 14.5)
|
||||
|
||||
# A short outage: unavailable for one or two polls, any time of day.
|
||||
h2 = solarfc.Hold()
|
||||
rs(st(1.1, at(3, 18, 5)), st(12.0, at(3, 18, 5)), at(3, 18, 6), h2)
|
||||
for label, now in (("18:47", at(3, 18, 47)), ("19:20", at(3, 19, 20)),
|
||||
("19:47", at(3, 19, 47)), ("20:55", at(3, 20, 55))):
|
||||
fo, _w, _g = rs(GONE, GONE, now, h2)
|
||||
check(f"unavailable at {label}: the 18:05 forecast is still used",
|
||||
fo is not None and fo.tomorrow.kwh == 12.0)
|
||||
fo, _w, _g = rs(GONE, GONE, at(3, 20, 55), solarfc.Hold())
|
||||
check("(nothing was ever read: still no forecast)", fo is None)
|
||||
# Daytime is as before: an outage longer than FRESH_S drops it.
|
||||
h3 = solarfc.Hold()
|
||||
rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h3)
|
||||
d_in = rs(GONE, GONE, at(4, 11, 50), h3)[0]
|
||||
d_out = rs(GONE, GONE, at(4, 12, 10), h3)[0]
|
||||
d_out2 = rs(st(6.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 14, 0), h3)[0]
|
||||
check("daytime: an outage under 3 h is covered, one over 3 h is not",
|
||||
d_in is not None and not d_in.kept and d_out is None)
|
||||
check("daytime: a sensor that stopped is stale after 3 h, as before "
|
||||
"(the hold does not start before the bell ends)", d_out2 is None)
|
||||
|
||||
# Round 2: today fresh, tomorrow's sensor stopped or down in the daytime.
|
||||
h9 = solarfc.Hold()
|
||||
rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
and fn.tomorrow.kwh == 12.0 and fn.kept)
|
||||
fs = rs(st(3.0, at(4, 10, 55)), tm, at(4, 11, 0), h9)[0]
|
||||
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
|
||||
fs is not None and fs.tomorrow is not None and not fs.kept)
|
||||
|
||||
# The bound: midnight + 3 h, and the day a figure is for.
|
||||
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
|
||||
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
|
||||
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
|
||||
fmid = rs(GONE, GONE, at(4, 2, 59), h2)[0]
|
||||
print(f" 02:59: {fmid and (fmid.today.kwh, fmid.tomorrow, fmid.kept)}")
|
||||
check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
|
||||
fmid is not None and fmid.today.kwh == 12.0 and fmid.tomorrow is None
|
||||
and fmid.kept and fmid.today.day0 == at(4, 0, 0))
|
||||
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
|
||||
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
|
||||
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
|
||||
check("03:01 (midnight + 3 h): not used any more",
|
||||
rs(GONE, GONE, at(4, 3, 1), h2)[0] is None)
|
||||
check("... and not the next evening either (older than one night)",
|
||||
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
|
||||
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
|
||||
check("... the next evening, with today fresh and tomorrow's sensor down, "
|
||||
"there is no tomorrow: the 4th's figure is not the 5th's",
|
||||
fe is not None and fe.today.kwh == 0.1 and fe.tomorrow is None)
|
||||
# Never today's figure for tomorrow.
|
||||
h4 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h4)
|
||||
# Next day 00:30: today's sensor is fresh again (4.0), tomorrow's is gone.
|
||||
fx = rs(st(4.0, at(4, 0, 20)), GONE, at(4, 0, 30), h4)[0]
|
||||
check("00:30: today is the fresh 4.0, and there is NO tomorrow "
|
||||
"(the 3rd's 'today' 3.0 and 'tomorrow' 12.0 are not the 5th's)",
|
||||
fx is not None and fx.today.kwh == 4.0 and fx.tomorrow is None
|
||||
and not fx.kept)
|
||||
fy = rs(st(4.0, at(4, 0, 20)), st(9.0, at(4, 0, 20)), at(4, 0, 30), h4)[0]
|
||||
check("00:30 with both fresh: tomorrow is the fresh 9.0, not the old 12.0",
|
||||
fy is not None and fy.tomorrow.kwh == 9.0 and not fy.kept)
|
||||
# A tomorrow sensor that is down while today's is fresh, in the evening: the
|
||||
# hold gives the tomorrow figure for tomorrow, never today's figure.
|
||||
h5 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h5)
|
||||
fz = rs(st(0.2, at(3, 21, 30)), GONE, at(3, 21, 40), h5)[0]
|
||||
check("21:40: today fresh, tomorrow down: tomorrow is the kept 12.0 (not "
|
||||
"today's 0.2 or 3.0)", fz is not None and fz.tomorrow.kwh == 12.0
|
||||
and fz.today.kwh == 0.2 and fz.kept)
|
||||
# A set-up fault is never covered.
|
||||
h6 = solarfc.Hold()
|
||||
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), h6)
|
||||
fb = rs(st("lots", at(3, 21, 30)), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
fu = rs(st(0.2, at(3, 21, 30), unit="W"), st(12.0, at(3, 21, 30)),
|
||||
at(3, 21, 40), h6)
|
||||
check("a bad value or a wrong unit is not covered by the hold",
|
||||
fb[0] is None and fb[1] == solarfc.BAD and fu[0] is None
|
||||
and fu[1] == solarfc.UNIT)
|
||||
# Several sensors: all or nothing.
|
||||
h7 = solarfc.Hold()
|
||||
two_t = [("sensor.t1", st(1.0, at(3, 17, 0))),
|
||||
("sensor.t2", st(2.0, at(3, 17, 0)))]
|
||||
two_m = [("sensor.m1", st(5.0, at(3, 17, 0))),
|
||||
("sensor.m2", st(6.0, at(3, 17, 0)))]
|
||||
solarfc.read_sum(two_t, two_m, at(3, 17, 5), h7)
|
||||
gone2 = [("sensor.t1", GONE), ("sensor.t2", GONE)]
|
||||
f2 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h7)
|
||||
check("three or two sensors: the kept figures are summed",
|
||||
f2[0] is not None and f2[0].tomorrow.kwh == 11.0
|
||||
and f2[0].today.kwh == 3.0 and f2[2] == ())
|
||||
h8 = solarfc.Hold()
|
||||
solarfc.read_sum(two_t[:1] + [("sensor.t2", GONE)], two_m, at(3, 17, 5), h8)
|
||||
f3 = solarfc.read_sum(gone2, [("sensor.m1", GONE), ("sensor.m2", GONE)],
|
||||
at(3, 22, 0), h8)
|
||||
check("one sensor never read fresh: no partial sum, the gap is named",
|
||||
f3[0] is None and any(g[:3] == ("today", 2, "sensor.t2") for g in f3[2]))
|
||||
# Restart: what the store holds comes back.
|
||||
saved = json.loads(json.dumps(h.dump()))
|
||||
hr = solarfc.Hold(saved)
|
||||
fr = rs(GONE, GONE, at(3, 23, 0), hr)[0]
|
||||
check("dump/load through JSON: the kept figures come back (40 min old: "
|
||||
"inside FRESH_S, so not 'kept' in the screens' sense)",
|
||||
fr is not None and not fr.kept and fr.tomorrow.kwh == 14.5
|
||||
and fr.at == at(3, 22, 20))
|
||||
junk = solarfc.Hold({"r": [["today", 7, "x", "y", "z", []],
|
||||
["tomorrow", T_ENT, "2026-11-04T00:00:00+00:00",
|
||||
-1, "2026-11-03T16:00:00+00:00", []],
|
||||
"bad", None]})
|
||||
check("a damaged store entry is dropped, not raised",
|
||||
rs(GONE, GONE, at(3, 22, 0), junk)[0] is None
|
||||
and solarfc.Hold(None).dump()["r"] == [] and solarfc.Hold(5).changed
|
||||
is False)
|
||||
hp = solarfc.Hold()
|
||||
rs(st(8.0, at(3, 9, 0), ), st(12.0, at(3, 9, 0)), at(3, 9, 5), hp)
|
||||
periods = [{"period_start": f"2026-11-04T{hh:02d}:00:00+01:00",
|
||||
"pv_estimate": 1.0} for hh in range(8, 16)]
|
||||
s_per = st(10.0, at(3, 17, 0))
|
||||
s_per["attributes"]["detailedForecast"] = periods
|
||||
rs(st(0.0, at(3, 17, 0)), s_per, at(3, 17, 5), hp)
|
||||
hq = solarfc.Hold(json.loads(json.dumps(hp.dump())))
|
||||
fq = rs(GONE, GONE, at(3, 22, 0), hq)[0]
|
||||
check("an hourly split (Solcast shape) survives the store",
|
||||
fq is not None and fq.tomorrow.periods
|
||||
and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07")
|
||||
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}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
DAY3 = 3
|
||||
|
||||
|
||||
def opts(cost=0.07):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=cost, static_profile=PROFILE,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT)
|
||||
return o
|
||||
|
||||
|
||||
def stops_at(last, *, down=()):
|
||||
"""Sensors that update until `last` and then show that reading for
|
||||
good. `down`: (from, to) windows in which they are unavailable."""
|
||||
def f(now, ent):
|
||||
for a, z in down:
|
||||
if a <= now < z:
|
||||
return GONE
|
||||
when = min(now, last)
|
||||
return st(0.4 if ent == T_ENT else 12.0, when)
|
||||
return f
|
||||
|
||||
|
||||
def never(now, ent):
|
||||
return st(0.0 if ent == T_ENT else 12.0, now - timedelta(hours=9))
|
||||
|
||||
|
||||
def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
keep_store=True, snap=False):
|
||||
"""-> (controller, rows, pre_rows). `pre`: the times the forecast is read
|
||||
before the first cycle (the evening). `restart`: a time in `pre` at which
|
||||
the add-on restarts: a new Controller on the saved store."""
|
||||
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
|
||||
clock = [pre[0] if pre else start]
|
||||
rows, pre_rows = [], []
|
||||
cur = [store]
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return sensors(clock[0], e)
|
||||
|
||||
def build(store):
|
||||
hass = H(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0})
|
||||
tmp = tempfile.mkdtemp(prefix="o27-")
|
||||
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
|
||||
# A house profile a few days old (300 W round the clock), so the day
|
||||
# plan is made as it is on a site that has run for a week.
|
||||
c.house_profile.load({d: {str(hh): 300.0 for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01",
|
||||
"2026-11-02")})
|
||||
return c, hass
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
|
||||
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
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
clock[0] = t_pre
|
||||
if restart is not None and t_pre == restart:
|
||||
# The saved file, through JSON; `keep_store=False` loses
|
||||
# the hold only (the control for the test).
|
||||
data = json.loads(json.dumps(cur[0].data))
|
||||
if not keep_store:
|
||||
data.pop(m.FC_HOLD_KEY, None)
|
||||
new = rp._Store()
|
||||
new.data.update(data)
|
||||
cur[0] = new
|
||||
c, hass = build(new)
|
||||
await c._read_forecast()
|
||||
pre_rows.append((t_pre, c.solar_fc is not None,
|
||||
bool(c.solar_fc and c.solar_fc.kept)))
|
||||
clock[0] = start
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
t = 0.0
|
||||
while t < minutes * 60:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if t == 0.0:
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
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(5.0, 10.0)
|
||||
rp_ = c.mode_runs
|
||||
rows.append(dict(
|
||||
t=t, fc_none=c.solar_fc is None,
|
||||
kept=bool(c.solar_fc and c.solar_fc.kept),
|
||||
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 {},
|
||||
charging=hass.plants["b"].p < -200.0,
|
||||
soc_b=hass.plants["b"].soc))
|
||||
t += 5.0
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
c.mirror, c.mirror_why = homeview.trade_verdict(
|
||||
c, c.fresh_tariff_plan(clock[0]), clock[0])
|
||||
finally:
|
||||
c.o = keep
|
||||
c.cards = homeview.battery_rows(c, False, True)
|
||||
c.block = homeview.solar_block(c, clock[0])
|
||||
c.hold_saved = json.loads(json.dumps(cur[0].data.get(
|
||||
m.FC_HOLD_KEY)))
|
||||
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, pre_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"]
|
||||
|
||||
|
||||
EVENING = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
LAST = at(DAY3, 17, 5)
|
||||
NIGHT_START = at(DAY3, 22, 0)
|
||||
|
||||
# a. a forecast that stops updating at sunset
|
||||
ca, ra, pa = drive27(opts(), stops_at(LAST), pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
la = nth(ra, -1, "last cycle")
|
||||
print(f" a. 22:00 with the 17:05 forecast: kept={la['kept']} on="
|
||||
f"{la['runs_on']} reason={la['reason']}; B charging "
|
||||
f"{sum(r['charging'] for r in ra)} of {len(ra)}; Why A {why_of(ca, A)} "
|
||||
f"B {why_of(ca, B)}; Home mirror {ca.mirror}")
|
||||
check("the forecast was read fresh before 17:05 and kept after it",
|
||||
any(not k for _t, ok, k in pa if _t <= LAST + timedelta(minutes=30))
|
||||
and all(ok for _t, ok, _k in pa)
|
||||
and all(k for _t, _ok, k in pa if _t >= at(DAY3, 20, 15)))
|
||||
check("22:00 night run: planned with the cycle-cost gap (needs 0.07 < 0.08)",
|
||||
present(la) and not la["fc_none"] and la["kept"]
|
||||
and la["runs_on"] is True and la["reason"] != "not_worth")
|
||||
check("... and B (Cheap hours) charges from the grid in the night period",
|
||||
sum(r["charging"] for r in ra) > len(ra) // 2
|
||||
and la["roles"].get(B) == modetrade.CHARGE
|
||||
and why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread")
|
||||
check("the day plan is made from the kept forecast (levels for Leftover "
|
||||
"solar), as it is from a fresh one",
|
||||
ca.day_plan is not None and ca.plan_targets() is not None
|
||||
and ca.block["state"] == "plan")
|
||||
check("Home's mirror of the gate allows it, as control does",
|
||||
ca.mirror is not None and ca.mirror_why == "allowed")
|
||||
|
||||
# b. no forecast all day
|
||||
cb, rb, pb = drive27(opts(), never, pre=EVENING + [at(DAY3, 21, 55)],
|
||||
start=NIGHT_START)
|
||||
lb = nth(rb, -1, "last cycle")
|
||||
print(f" b. no forecast all day: on={lb['runs_on']} reason={lb['reason']}; "
|
||||
f"B charging {sum(r['charging'] for r in rb)}; Why A {why_of(cb, A)} "
|
||||
f"B {why_of(cb, B)}")
|
||||
check("with no forecast all day the night run still needs 1.5x (0.105 > 0.08)"
|
||||
": not worth, no charge, 'Prices differ too little'",
|
||||
present(lb) and lb["fc_none"] and lb["runs_on"] is False
|
||||
and lb["reason"] == "not_worth"
|
||||
and not any(r["charging"] for r in rb)
|
||||
and why_of(cb, A) == "no_spread" and why_of(cb, B) == "no_spread"
|
||||
and not any(ok for _t, ok, _k in pb))
|
||||
check("... and there is no day plan (Leftover solar takes solar only)",
|
||||
cb.day_plan is None and cb.plan_targets() is None
|
||||
and cb.block["state"] == "no_forecast")
|
||||
check("... Home's mirror refuses it too, for the gap ('not_worth')",
|
||||
cb.mirror is None and cb.mirror_why == "not_worth")
|
||||
check("... and nothing is saved for the night (no fresh reading was made)",
|
||||
cb.hold_saved is None or cb.hold_saved.get("r") == [])
|
||||
|
||||
# c. an evening restart
|
||||
cc, rc, pc = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55))
|
||||
lc = nth(rc, -1, "last cycle")
|
||||
check("an evening restart (new Controller on the saved store) keeps the "
|
||||
"forecast: the night run is planned and charges",
|
||||
present(lc) and lc["kept"] and lc["runs_on"] is True
|
||||
and sum(r["charging"] for r in rc) > len(rc) // 2
|
||||
and present(nth(pc, -1, "last read")) and pc[-1][1] and pc[-1][2])
|
||||
cc0, rc0, pc0 = drive27(opts(), stops_at(LAST),
|
||||
pre=EVENING + [at(DAY3, 21, 55)], start=NIGHT_START,
|
||||
restart=at(DAY3, 21, 55), keep_store=False)
|
||||
lc0 = nth(rc0, -1, "last cycle")
|
||||
check("(control: the same restart with the store lost is blind, 1.5x)",
|
||||
present(lc0) and lc0["fc_none"] and lc0["runs_on"] is False
|
||||
and present(nth(pc0, -1, "last read")) and not pc0[-1][1])
|
||||
|
||||
# d. a one-hour outage at 18:47
|
||||
OUT = (at(DAY3, 18, 47), at(DAY3, 19, 47))
|
||||
quarter = [at(DAY3, 16, 0) + timedelta(minutes=15 * i) for i in range(24)]
|
||||
cd, rd, pd = drive27(opts(), stops_at(at(DAY3, 18, 5), down=[OUT]),
|
||||
pre=quarter + [at(DAY3, 21, 55)], start=NIGHT_START)
|
||||
ld = nth(rd, -1, "last cycle")
|
||||
inside = [(t_, ok) for t_, ok, _k in pd if OUT[0] <= t_ < OUT[1]]
|
||||
print(f" d. reads inside the outage: {len(inside)}, with a forecast: "
|
||||
f"{sum(ok for _t, ok in inside)}")
|
||||
check("a one-hour outage at 18:47 does not drop the forecast "
|
||||
"(every read from 16:00 to 22:00 has one)",
|
||||
present(pd) and len(inside) >= 4 and all(ok for _t, ok, _k in pd))
|
||||
check("... and the 22:00 night run is planned and charges",
|
||||
present(ld) and ld["runs_on"] is True
|
||||
and sum(r["charging"] for r in rd) > len(rd) // 2)
|
||||
|
||||
# e. the bound: midnight + 3 h
|
||||
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
|
||||
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 2, 30)],
|
||||
start=at(4, 2, 30))
|
||||
le = nth(re_, -1, "last cycle")
|
||||
check("02:30 (before midnight + 3 h): the figure from 17:05 is still used, "
|
||||
"as today's; the night run is planned",
|
||||
present(le) and le["kept"] and le["runs_on"] is True)
|
||||
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 3, 30)],
|
||||
start=at(4, 3, 30))
|
||||
lf = nth(rf, -1, "last cycle")
|
||||
check("03:30 (after midnight + 3 h): a figure from before the night is not "
|
||||
"used: no forecast, 1.5x, not worth, no charge",
|
||||
present(lf) and lf["fc_none"] and lf["runs_on"] is False
|
||||
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. The screens")
|
||||
blk = ca.block
|
||||
print(f" forecast line: {blk['line']!r} (state {blk['state']})")
|
||||
check("the forecast line says when it was read and that it is used for "
|
||||
"tonight's plan", blk["line"] == "Solar forecast read 17:05, used for "
|
||||
"tonight's plan.")
|
||||
check("the same line comes from the Forecast tab (dayview) on today",
|
||||
homeview.line_solar(0.4, 0.3, "17:05", kept=True) == blk["line"]
|
||||
and homeview.line_solar(0.4, 0.3, "17:05").startswith(
|
||||
"Solar still to come today"))
|
||||
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))
|
||||
k_en = en["home_words"]["solar"]["kept"]
|
||||
k_nl = nl["home_words"]["solar"]["kept"]
|
||||
print(f" EN {k_en!r}\n NL {k_nl!r}")
|
||||
check("EN and NL both say it, with the time, and the NL is not the English",
|
||||
"{at}" in k_en and "{at}" in k_nl and k_en != k_nl
|
||||
and "plan" in k_en and "vanavond" in k_nl)
|
||||
check("a daytime reading keeps the old line (daytime unchanged)",
|
||||
"Updated 09:05" in homeview.line_solar(1.0, 0.7, "09:05")
|
||||
and "Bijgewerkt" in json.dumps(nl["home_words"]["solar"]["line"]))
|
||||
check("no forecast: the old 'No solar forecast now' line, no 'kept'",
|
||||
cb.block["state"] == "no_forecast"
|
||||
and "tonight" not in cb.block["line"])
|
||||
check("control and Batteries screen agree on the 22:00 run: not 'no_spread'",
|
||||
why_of(ca, A) != "no_spread" and why_of(ca, B) != "no_spread"
|
||||
and la["runs_on"] is True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,374 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,451 @@
|
||||
"""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 coming (the gate follows in the same cycle) and going mid-run:
|
||||
a run under way keeps its gap to its end, the next run needs the wider one;
|
||||
a forecast unavailable for one or two reads mid-run does not stop or
|
||||
restart the charge.
|
||||
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, solarfc # 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 = []
|
||||
|
||||
|
||||
class _NoHold(solarfc.Hold):
|
||||
"""OPT-27: a hold that never stands in for a lost sensor."""
|
||||
|
||||
def stand_in(self, *a, **k):
|
||||
return None
|
||||
|
||||
|
||||
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"
|
||||
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
|
||||
|
||||
|
||||
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 take(c, now):
|
||||
"""What Home and the Batteries screen say now, and 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)."""
|
||||
keep = c.o
|
||||
c.o = dict(keep, arbitrage_enabled=True)
|
||||
try:
|
||||
mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
||||
finally:
|
||||
c.o = keep
|
||||
return dict(home=homeview.build_plan(c, now, False, True),
|
||||
cards=homeview.battery_rows(c, False, True),
|
||||
mirror=mirror, mirror_why=why)
|
||||
|
||||
|
||||
def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
house_w=300.0, soc0=None, snap_at=()):
|
||||
"""-> (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 = []
|
||||
snaps = {}
|
||||
|
||||
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
|
||||
# OPT-27: a sensor that is missing is covered for up to 3 h by its last
|
||||
# fresh reading. This file is about what the plan does when the
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
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 or t % TARIFF_REFRESH_S == 0.0:
|
||||
# The task's own 30 min wait is wall clock, so the
|
||||
# simulated clock restarts it: one refresh per period.
|
||||
if task is not None and t > 0.0:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task = asyncio.create_task(c.run_tariff_refresh())
|
||||
for _ in range(500):
|
||||
if c.tariff_plan is not None and (
|
||||
c.tariff_at is not None and c.tariff_at >= now):
|
||||
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,
|
||||
soc_b=hass.plants["b"].soc,
|
||||
cheap_end=None if rp_ is None or rp_.cheap is None
|
||||
else rp_.cheap.end))
|
||||
if t in snap_at:
|
||||
snaps[t] = take(c, now)
|
||||
t += tick
|
||||
last = take(c, clock[0])
|
||||
c.home_plan, c.cards = last["home"], last["cards"]
|
||||
c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
|
||||
c.snaps = snaps
|
||||
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 (a run under way keeps its gap)")
|
||||
CUT = 600.0 # 10 min in
|
||||
SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h
|
||||
|
||||
|
||||
def going(t):
|
||||
return "fresh" if t < CUT else "gone"
|
||||
|
||||
|
||||
def coming(t):
|
||||
return "gone" if t < CUT else "fresh"
|
||||
|
||||
|
||||
def flap(n_reads):
|
||||
"""Unavailable for `n_reads` forecast reads (60 s apart) from CUT."""
|
||||
return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh"
|
||||
|
||||
|
||||
MID, AFTER = 3000.0, 7800.0
|
||||
cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC,
|
||||
snap_at=(MID, AFTER))
|
||||
cc, rc = drive(opts(0.07), fc=coming, minutes=25)
|
||||
_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0,
|
||||
"the cheap run's end")
|
||||
_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9
|
||||
lost = [r for r in rg if r["fc_none"]]
|
||||
print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from "
|
||||
f"t={lost[0]['t'] if lost else None}")
|
||||
check("going: the run planned with the forecast ends before the snapshot "
|
||||
"taken after it (the scenario is long enough)",
|
||||
present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost)
|
||||
and lost[0]["t"] - CUT <= 60.0)
|
||||
check("going: the forecast read is lost mid-run and the run under way KEEPS "
|
||||
"its gap: the plan stays on and B keeps charging, cycle after cycle, "
|
||||
"until B is full or the run ends",
|
||||
all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE
|
||||
or r["soc_b"] >= 94.0)
|
||||
for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0)
|
||||
and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg
|
||||
if 60.0 <= r["t"] < _end_t - 60.0)
|
||||
and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0)
|
||||
check("going: from the run's end the wider gap is back: the next run is "
|
||||
"not_worth (needs 0.105 at 0.07), no new run, and no grid charge",
|
||||
all(r["runs_on"] is False and r["reason"] == "not_worth"
|
||||
for r in rg if r["t"] >= _end_t + 30.0)
|
||||
and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0))
|
||||
_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER)
|
||||
check("going, Home agrees mid-run: its mirror still allows, the Batteries "
|
||||
"screen says it charges from the grid, and Home does not say the gap "
|
||||
"is too small",
|
||||
_sm is not None and _sm["mirror"] is not None
|
||||
and first((b for b in _sm["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge"
|
||||
and not any("price gap is too small" in ln
|
||||
for ln in _sm["home"]["lines"]))
|
||||
check("going, Home agrees after the run: its mirror refuses, the Batteries "
|
||||
"screen says 'differ too little' and Home says the gap is too small",
|
||||
_sa is not None and _sa["mirror"] is None
|
||||
and first((b for b in _sa["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "no_spread"
|
||||
and any("price gap is too small" in ln for ln in _sa["home"]["lines"]))
|
||||
for n in (1, 2):
|
||||
cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC,
|
||||
snap_at=(CUT + 30.0,))
|
||||
gone = [r for r in rf if r["fc_none"]]
|
||||
print(f" flap, {n} read(s) lost: {len(gone)} cycles without a "
|
||||
f"forecast; B charging in {sum(r['charging'] for r in rf)} of "
|
||||
f"{len(rf)}")
|
||||
check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the "
|
||||
f"plan stays on and B charges without a break (no stop, no "
|
||||
f"restart)",
|
||||
bool(gone) and all(r["runs_on"] is True for r in rf)
|
||||
and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE
|
||||
for r in rf if r["t"] >= 60.0))
|
||||
sf = cf.snaps.get(CUT + 30.0)
|
||||
check(f"flap, {n} read(s): Home agrees while the read is lost (mirror "
|
||||
f"allows, Batteries 'charges from the grid')",
|
||||
sf is not None and sf["mirror"] is not None
|
||||
and first((b for b in sf["cards"] if b["name"] == B),
|
||||
"a card")["why_key"] == "grid_charge")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
fcm = flips(rc)
|
||||
print(f" coming: forecast read arrives at cycle/t {fcm}")
|
||||
check("coming (no run under way before): 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")
|
||||
@@ -247,6 +247,12 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
|
||||
tomorrow = False
|
||||
if name == "sunny":
|
||||
sun_today, sun_tomorrow = 40.0, 40.0
|
||||
if name not in ("nofc", "off", "stale") and \
|
||||
opts.get("battery_modes_enabled"):
|
||||
# TARIFF-24: with a forecast the gap needed is the cycle cost, not
|
||||
# 1.5 x it. These scenarios were written for the old gate (0.15 at
|
||||
# the shipped 0.10), so they keep it: the cost is 0.15.
|
||||
opts["cycle_cost_threshold_eur"] = 0.15
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
|
||||
Reference in New Issue
Block a user