CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -17,8 +17,95 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
|
||||
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
|
||||
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
|
||||
|
||||
### TARIFF-10 - a configured provider is fetched, whether or not you trade
|
||||
|
||||
**`amain()` started the background tariff refresh only when
|
||||
`arbitrage_enabled` was on, and that option ships `false`.**
|
||||
`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 price
|
||||
provider configured **nothing was ever fetched**: no plan resolved,
|
||||
`/data/price_history.json` never created, the *Price history* status row
|
||||
reading `empty` for the life of the install, and TARIFF-09's short-horizon
|
||||
warning unable to fire because there was no fetch to warn about.
|
||||
|
||||
Measured over a 40 h soak of two instances side by side on the same feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
|
||||
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
|
||||
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
|
||||
still fetches, validates and publishes prices and changes no charging
|
||||
decision"* — all in one change. `config.yaml` has said the same since
|
||||
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
|
||||
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
|
||||
claim, plus a second one that had gone stale in the other direction (*"nothing
|
||||
consumes the price series yet"*, untrue since OPT-04). All four now say what
|
||||
the add-on does.
|
||||
|
||||
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
|
||||
|
||||
```
|
||||
provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
Every configuration that started the refresh before still starts it. The second
|
||||
term is not redundant and is not there for symmetry: `price_provider: off` with
|
||||
a `static_profile` and `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 EUR/kWh day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
|
||||
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
|
||||
has been resolved yet"* on every cycle - silently disarming a strategy the UI
|
||||
says is running.
|
||||
|
||||
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
|
||||
off, `resolve_tariff()` answers out of the static profile without opening a
|
||||
socket; and the true shipped default - no provider, no trading - starts **no
|
||||
task at all**. What decides whether a request leaves the machine is
|
||||
`price_provider`, and only that.
|
||||
|
||||
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
|
||||
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
|
||||
inferred from the gate having moved: `Controller.cycle()` is driven on a
|
||||
resolved provider plan whose cheapest firm hour is now, with the peak guard
|
||||
asserted clear, and `arbitrage_enabled` is the single option that moves between
|
||||
the rows.
|
||||
|
||||
| `arbitrage_enabled` | cheapest hour is now | commanded |
|
||||
|---|---|---|
|
||||
| `false` | yes | **+180 W** (`loop -> 180 W`) |
|
||||
| `false` | no | **+180 W** (`loop -> 180 W`) |
|
||||
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
|
||||
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
|
||||
|
||||
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
|
||||
decision rather than an inheritance.** A provider-backed plan exists on every
|
||||
configured site from this commit, so *Cheap window* - which feeds the
|
||||
maintenance cycle's grid top-up, i.e. the one place a price reaches something
|
||||
that **moves energy** without trading being armed - could now read it. It does
|
||||
not: `self.cheap` still comes from `static_status()`, and on a provider-only
|
||||
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
|
||||
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
|
||||
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
|
||||
the list of what adopting it would take.
|
||||
|
||||
**The check that would have caught this** is `test_price.py` section 13, and it
|
||||
grades **reachability from a configuration**, not the shape of an `if`. The
|
||||
guards in `amain()` are *evaluated* against real option dicts, whatever they are
|
||||
spelled as; the call graph under `app/` is walked to see whether the tasks those
|
||||
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
|
||||
then the fetch is **driven** with a counting transport, because a graph is an
|
||||
argument and a counted request is an observation. The shipped defect - the gate
|
||||
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
|
||||
|
||||
### TARIFF-12 - a returned degradation is not a refresh
|
||||
|
||||
**`run_tariff_refresh()` promised in its own docstring that "a failure does not
|
||||
@@ -80,8 +167,21 @@ plan during an outage: FCST-03 exists to stop stale prices being acted on, and
|
||||
the socket**: the transport raises the way aiohttp does, `load_prices()` returns
|
||||
`no_data`, `_degrade()` builds the §11.4 plan, and the shipped
|
||||
`run_tariff_refresh()` decides. A check that patched `resolve_tariff` to *raise*
|
||||
would have passed against the defect. Ten mutations, ten reds, no survivors -
|
||||
see `DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
|
||||
would have passed against the defect. Eleven mutations, eleven reds, no
|
||||
survivors - see `DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
|
||||
|
||||
**Merged after TARIFF-10, which widened *which installs run this task*, and the
|
||||
two configurations it newly keeps alive are graded rather than reasoned about.**
|
||||
A provider with trading disarmed is the case this guard was designed for and is
|
||||
covered by every driven check here, because nothing in `run_tariff_refresh()`
|
||||
reads a trading option at all - asserted on the AST symbols rather than on the
|
||||
source text, after two drafts of that check went red on a correct tree for
|
||||
saying "arbitrage" in a docstring and in a log line. `price_provider: off` + a
|
||||
`static_profile` resolves to the CONFIGURED product (`origin: "static"`), so
|
||||
`standing.from_feed` is false on every refresh and the guard never latches: the
|
||||
plan is regenerated for the current window exactly as it always was. Both are
|
||||
driven, and a mutation that makes the guard depend on `arbitrage.enabled()` is
|
||||
red on five named checks.
|
||||
|
||||
|
||||
### TARIFF-09 - the feed is asked for the range the caller wanted
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# TARIFF-10 — fetching a price and trading on one are two decisions
|
||||
|
||||
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
||||
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
||||
this card moves nothing that moves energy.**
|
||||
|
||||
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
||||
limitation FCST-04 recorded and did not decide.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, and why it was invisible
|
||||
|
||||
`app/main.py`'s `amain()` read:
|
||||
|
||||
```
|
||||
if arbitrage.enabled(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
```
|
||||
|
||||
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
||||
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
||||
|
||||
So on a shipped install with a provider configured:
|
||||
|
||||
* no HTTPS request was ever made to the feed;
|
||||
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
||||
* `/data/price_history.json` was never created;
|
||||
* the *Price history* status row read `empty` for the life of the install;
|
||||
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
||||
fetch and there was no fetch.
|
||||
|
||||
Measured, not inferred — a 40 h soak, two instances, one feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
||||
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
||||
shipped default, turning a provider on still fetches, validates and publishes
|
||||
prices and changes no charging decision."* `config.yaml` and both translations
|
||||
said the same, and additionally carried a claim that had gone stale in the other
|
||||
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
||||
shipped the consumer. The code was the only surface that disagreed with all of
|
||||
them, and no check compared the two.
|
||||
|
||||
The dating is worth being exact about, because it says this was never a
|
||||
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
||||
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
||||
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
||||
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
||||
|
||||
## 2. Why a check on the `if` would not have caught it
|
||||
|
||||
The `if` was exactly what somebody meant to write, and its comment argued for
|
||||
it honestly:
|
||||
|
||||
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
||||
> resolved plan … so on a default install this task would be network traffic in
|
||||
> aid of an attribute nobody reads.
|
||||
|
||||
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
||||
plan a *store* and a *status row*. A pin on the expression would have gone red
|
||||
on the fix and green on the defect. What was wrong is the **set of
|
||||
configurations from which a fetch is reachable**, so that is what
|
||||
`test_price.py` section 13 grades:
|
||||
|
||||
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
||||
against real option dicts — whatever the guard is spelled as;
|
||||
2. the call graph under `app/` is walked to see whether the coroutines those
|
||||
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
||||
3. and then the fetch is **driven**, with a counting transport, a real
|
||||
`Controller` and a real store path, because a reachability graph is an
|
||||
argument and a counted request is an observation.
|
||||
|
||||
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
||||
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
||||
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
||||
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
||||
driven rows go **green** while only the structural rows stay red. Without it
|
||||
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
||||
|
||||
What the instrument **cannot** see is printed on every run: a coroutine started
|
||||
by anything but `create_task`; a guard that is not an enclosing `if` in
|
||||
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
||||
— two same-named functions as one node, which makes every *reachable* answer an
|
||||
**upper** bound and every *not reachable* answer the strong one.
|
||||
|
||||
## 3. The gate, and why it is an `or`
|
||||
|
||||
```
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
The first term is what this card adds. **The second term is the old gate, kept
|
||||
verbatim, and it is not there for symmetry.**
|
||||
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
||||
no-prices-at-all case), and it **trades**. Measured on this branch:
|
||||
|
||||
```
|
||||
opts: price_provider off, arbitrage_enabled true,
|
||||
cycle_cost_threshold_eur 0.05,
|
||||
static_profile 0.20 night / 0.284 day
|
||||
plan : static_profile from static_profile, 48 h, 48 points
|
||||
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
||||
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
||||
dearest and clear of the 0.0750 this cycle had to cover"
|
||||
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
||||
```
|
||||
|
||||
Gating the refresh on the provider **alone** — which is the literal reading of
|
||||
the ticket's second acceptance criterion — would leave that site's
|
||||
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
||||
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
||||
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
||||
from inside the code instead of from the configuration.
|
||||
|
||||
So the gate is a **strict widening**: every configuration that started the task
|
||||
before still starts it, and `test_price.py` grades that as its own row rather
|
||||
than leaving it to prose.
|
||||
|
||||
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
||||
this rather than quietly declaring victory.** The criterion reads *"with
|
||||
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
||||
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
||||
letter of it is not met in one configuration (provider off, trading armed),
|
||||
because that configuration keeps the task it already had. The harm it names is:
|
||||
|
||||
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
||||
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
||||
* the true shipped default — no provider, no trading — starts **no task at
|
||||
all**.
|
||||
|
||||
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
||||
decides whether a request leaves the machine is `price_provider`. **If the
|
||||
owner wants the literal reading instead, it is a one-token change** (drop the
|
||||
`or` term) **and it costs the site in the measurement above its trading.** That
|
||||
is the trade, stated rather than chosen silently.
|
||||
|
||||
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
||||
|
||||
**Decision: do not wire it in this card. `self.cheap` stays on
|
||||
`static_status()`.**
|
||||
|
||||
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
||||
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
||||
static_status(o, now)`, which builds its **own** static plan and calls
|
||||
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
||||
configured site, so the limitation stops being structural and becomes a choice.
|
||||
|
||||
**It is not cosmetic, and that is the whole reason for the decision.**
|
||||
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
||||
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
||||
something that **moves energy** on an install that has not armed trading.
|
||||
|
||||
⚠️ **And the direction it would move is not "a slightly different window".**
|
||||
`static_status()` returns `(False, "no static profile")` whenever
|
||||
`static_profile` is empty, so today a provider-only site — a plausible shape
|
||||
for a dynamic-tariff install, and the shape this card makes work — has
|
||||
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
||||
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
||||
a strictly larger behaviour change than this card's headline, and it belongs to
|
||||
whoever owns the maintenance cycle rather than to the ticket that made it
|
||||
reachable.
|
||||
|
||||
Three further reasons, in the order they would bite:
|
||||
|
||||
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
||||
already refuses `static_profile` alongside the Home Assistant price
|
||||
entities *because* `read_inputs()` picks one and the other disappears. A
|
||||
provider plan would be a third writer of the same attribute with no rule
|
||||
saying which wins.
|
||||
2. **The freshness question is not answered for this consumer.** The plan
|
||||
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
||||
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
||||
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
||||
path means **wait for sun**, which is a real decision nobody has taken.
|
||||
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
||||
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
||||
`False` when THIS hour is provisional or is not in the series at all
|
||||
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
||||
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
||||
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
||||
either way — and it is a change to the shipped maintenance cycle that needs
|
||||
its own measurement rather than a side effect of this one.
|
||||
|
||||
**What adopting it would take**, so the next card does not start from scratch:
|
||||
|
||||
* a rule for which source wins when both a provider plan and a
|
||||
`static_profile` are configured, enforced in `validate.py` the way the
|
||||
entity/profile conflict already is;
|
||||
* a decision on what an **expired** or **absent** plan means for the grid
|
||||
top-up, stated as a default rather than left to `None`;
|
||||
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
||||
currently one assignment from one function;
|
||||
* a check that the maintenance top-up actually fires and does not fire on the
|
||||
provider plan — driven through `cycle()`, not asserted on the attribute;
|
||||
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
||||
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
||||
plan, DOCS does not claim it does, and moving one without the other is
|
||||
already red.
|
||||
|
||||
## 5. The *Arbitrage* status row, re-read and left alone
|
||||
|
||||
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
||||
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
||||
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
||||
so `self.arb is None` **iff** the option is off. The string still names the
|
||||
option that is actually off, and under Option A it gains a job it did not have:
|
||||
on a site with a provider configured it is the row that tells an operator the
|
||||
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
||||
with a note at the call site saying it was checked.
|
||||
|
||||
## 6. What this card did not do
|
||||
|
||||
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
||||
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
||||
the thresholds, the SoC bounds or the peak interlock.
|
||||
* It did not wire the provider plan into the *Tariff* status row or into
|
||||
`self.cheap` — §4.
|
||||
* It did not pick a version number (PKG-06).
|
||||
|
||||
## 7. Mutation ledger
|
||||
|
||||
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
||||
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
||||
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
||||
|
||||
| id | mutation | Δ bytes | result |
|
||||
|---|---|---|---|
|
||||
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
||||
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
||||
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
||||
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
||||
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
||||
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
||||
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
||||
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
||||
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
||||
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
||||
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
||||
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
||||
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
||||
|
||||
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
||||
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
||||
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
||||
with surrounding whitespace, and the `.strip()` predates this card — it was
|
||||
carried into `provider_id()` from the three call sites it replaced. The
|
||||
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
||||
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
||||
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
||||
re-run as **M10b**, red on two named checks.
|
||||
|
||||
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
||||
first draft fed five minutes of telegrams. Whether that **covers** the current
|
||||
billing quarter depends on where the wall clock sits inside it, so the peak
|
||||
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
||||
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
||||
baseline did not trade, and "trading off never trades" would have been graded
|
||||
on a fixture in which nothing could trade either way. It is now 900 s of
|
||||
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
||||
|
||||
## 8. What is still open
|
||||
|
||||
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
||||
configuration its plan. **Owner's call.**
|
||||
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
||||
* The *Tariff* status row still shows the static-profile sentence on a
|
||||
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
||||
a pair with `DOCS.md` by FCST-04 and untouched here.
|
||||
@@ -175,6 +175,71 @@ and the 30-minute lag reported on that row is untouched by anything here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
||||
|
||||
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
||||
installs run this task** — `if arbitrage.enabled(opts)` became
|
||||
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
||||
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
||||
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
||||
interaction needs reading rather than trusting.
|
||||
|
||||
**Nothing in the decisions above changed.** Two configurations are newly
|
||||
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
||||
reasoned about:
|
||||
|
||||
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
||||
This is precisely the configuration the guard was designed for: the plans are
|
||||
`origin == "provider"`, and a dead feed must not delete one that is still
|
||||
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
||||
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
||||
than assumed — over the AST's symbols and the non-docstring string constants,
|
||||
because two earlier drafts of that check were **red against a correct tree**:
|
||||
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
||||
not read, the second matched the log line's "arbitrage, where it is armed".
|
||||
A substring test over a function grades its prose as much as its behaviour,
|
||||
which is habit 4 in a third costume. A fixture that set the option and
|
||||
asserted no difference would have been the other defect — comparing a thing
|
||||
against itself.
|
||||
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
||||
`or` exists to protect, and the one where a latching guard would do real harm.
|
||||
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
||||
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
||||
is false on every refresh, `keep` is false on every refresh, and the plan is
|
||||
regenerated for the current window every 30 minutes exactly as before this
|
||||
card. **Driven, twice in a row**, because a guard that latched here would
|
||||
freeze a static site's plan at its first window for the life of the install —
|
||||
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
||||
check as well as on the fallback one.
|
||||
|
||||
Two further facts settle the rest of the interaction:
|
||||
|
||||
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
||||
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
||||
branch, before `keep` is computed — so which of two plans is kept can neither
|
||||
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
||||
unaffected either way.
|
||||
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
||||
which after TARIFF-10 is false on a site that fetches without trading; it now
|
||||
says "arbitrage, **where it is armed**, stops with it".
|
||||
|
||||
All eleven mutations were **re-driven on the merged tree** rather than carried
|
||||
over — the surrounding code changed, so a red against the old base is not
|
||||
evidence about the new one. Eleven red, no survivors, every revert
|
||||
fingerprinted. The added member is M11: making the guard conditional on
|
||||
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
||||
|
||||
⚠️ **And one process note that belongs in this file.** This card's mutation
|
||||
harness was written to a shared scratchpad path and was **overwritten mid-run
|
||||
by another agent's harness for a different card**, pointing at a different
|
||||
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
||||
caught because the harness fingerprints the tree after every revert and the
|
||||
runs are what proved this worktree was never left mutated; the harness now
|
||||
lives at a card-specific path. **A harness that edits source must not share a
|
||||
name, a directory or a backup path with another one.**
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
||||
|
||||
@@ -1346,12 +1346,26 @@ does, and only after you have read why.
|
||||
> release ships the five provider plugins, the canonical all-in format they
|
||||
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||
> shipped default, turning a provider on still fetches, validates and publishes
|
||||
> prices and changes no charging decision.
|
||||
> shipped default, **selecting a provider is what starts the fetch**: the feed
|
||||
> is read every 30 minutes, validated, and the day recorded in
|
||||
> `/data/price_history.json`, which the *Price history* status row reports and
|
||||
> the add-on log names at every refresh. No charging decision changes.
|
||||
>
|
||||
> ⚠️ **That has been the documented behaviour since OPT-04 and was not the
|
||||
> actual behaviour until TARIFF-10.** The background refresh was started only
|
||||
> when `arbitrage_enabled` was on — and it ships `false` — so an install with a
|
||||
> provider configured fetched nothing at all, never created the store, and read
|
||||
> *Price history: empty* for as long as it ran. If you configured a provider and saw an empty
|
||||
> Price history row, that is what you were looking at. Trading is unchanged and
|
||||
> is still `arbitrage_enabled` alone.
|
||||
>
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed.
|
||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||||
> that window.** A provider-backed plan now exists on every configured site,
|
||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
@@ -1678,6 +1692,14 @@ towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||||
retires itself if the refresh stops answering, at which point arbitrage stops
|
||||
with it.
|
||||
|
||||
⚠️ **That refresh is not gated on this switch** (TARIFF-10). It runs when a
|
||||
`price_provider` is configured *or* when arbitrage is armed, so turning
|
||||
arbitrage off does not turn the price feed off — and a site with no provider
|
||||
that arms arbitrage on a `static_profile` keeps the plan it has always had. The
|
||||
only thing that decides whether a request leaves the machine is
|
||||
`price_provider`: with it `off`, the refresh resolves the static profile in
|
||||
memory and opens no connection.
|
||||
|
||||
### Site
|
||||
|
||||
| option | meaning |
|
||||
|
||||
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
|
||||
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?
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
try:
|
||||
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
|
||||
@@ -3521,6 +3575,15 @@ class Controller:
|
||||
or an outage would freeze the operator's static profile at the window
|
||||
it was generated for.
|
||||
|
||||
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
|
||||
WORTH SAYING. Since that card this task also runs on a site with a
|
||||
provider configured and trading DISARMED, so "keep the plan" is a
|
||||
decision about what the add-on has LOOKED UP, not about what it may
|
||||
trade on. It is also strictly downstream of `resolve_tariff()`: the
|
||||
§8.3 carry into `price_history` happens on the `series` branch inside
|
||||
that call, before this decision exists, so which of two plans is kept
|
||||
cannot add or lose a recorded day.
|
||||
|
||||
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
|
||||
fetch that produced the standing plan happened, and
|
||||
`fresh_tariff_plan()` judges the age from it - re-stamping it here
|
||||
@@ -3548,9 +3611,10 @@ class Controller:
|
||||
_LOG.warning(
|
||||
"tariff refresh degraded (%s); KEEPING the plan "
|
||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
||||
"so it retires %.0f s after that fetch and arbitrage "
|
||||
"stops with it - a kept plan is never presented as a "
|
||||
"current one.", plan.words, self.tariff_at,
|
||||
"so it retires %.0f s after that fetch; arbitrage, "
|
||||
"where it is armed, stops with it. A kept plan is "
|
||||
"never presented as a current one.", plan.words,
|
||||
self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
else:
|
||||
@@ -4416,6 +4480,14 @@ class Controller:
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
#
|
||||
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
|
||||
# saying because that card is about the two switches being
|
||||
# different. `self.arb` is None if and only if
|
||||
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
|
||||
# still names the option that is actually off - and on a site with a
|
||||
# provider configured it is now the row that says the prices
|
||||
# arriving on the *Price history* row above are moving nothing.
|
||||
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
|
||||
else self.arb.explain()),
|
||||
]
|
||||
@@ -4668,11 +4740,16 @@ 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 ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||
# the tick, which is pure - so on a default install this task would be
|
||||
# network traffic in aid of an attribute nobody reads.
|
||||
if arbitrage.enabled(opts):
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
|
||||
if tariff_refresh_wanted(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||
|
||||
@@ -766,6 +766,47 @@ def price_option(opts: dict, key: str):
|
||||
return v
|
||||
|
||||
|
||||
def provider_id(opts: dict) -> str:
|
||||
"""The selected plugin id, normalised. `"off"` when none is selected.
|
||||
|
||||
⚠️ ONE SPELLING OF THIS EXPRESSION, AND TARIFF-10 IS WHY IT IS A FUNCTION.
|
||||
`str(price_option(opts, "price_provider") or "off").strip()` was written out
|
||||
twice - in `load_prices()` below and in `static_tariff.resolve_tariff()` -
|
||||
and main.py's startup gate would have been a third. "Is a provider
|
||||
configured" is now a DECISION the add-on takes (it is what starts the
|
||||
background refresh), and a decision spelled out at three call sites is three
|
||||
places to disagree about whether `" off "` or `None` counts.
|
||||
⚠️ `validate.py` deliberately keeps its own reader: it validates the RAW
|
||||
options blob, including one the Supervisor never saw, so it must not route
|
||||
through `price_option()`'s defaults. Note what this does NOT do: it does not
|
||||
decide
|
||||
whether the id names a shipped plugin - `load_prices()` and validate.py's
|
||||
rule 7 own that, and answering "unknown" here would turn a typo into
|
||||
"no provider" instead of into a startup refusal.
|
||||
"""
|
||||
return str(price_option(opts, "price_provider") or "off").strip()
|
||||
|
||||
|
||||
def provider_configured(opts: dict) -> bool:
|
||||
"""Has the operator selected a price feed at all? (TARIFF-10)
|
||||
|
||||
THE DEFECT THIS PREVENTS: the background tariff refresh used to start only
|
||||
when `arbitrage_enabled` was on, which ships `false` - so a site with a
|
||||
provider configured fetched NOTHING, ever. `/data/price_history.json` was
|
||||
never created, the *Price history* status row read `empty` forever, and
|
||||
TARIFF-09's short-horizon warning could not fire because no fetch happened
|
||||
to warn about. Measured over a 40 h two-instance soak: zero feed requests
|
||||
and `present: false` across all 802 samples on the shipped default, against
|
||||
a resolved plan on the same feed with arbitrage armed.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH AND MUST NEVER BECOME ONE. It answers
|
||||
"fetch prices?"; `arbitrage.enabled()` answers "move energy on them?", and
|
||||
§10.1's priority 3 is gated on the second alone. See
|
||||
`main.tariff_refresh_wanted()` for the one place the two meet.
|
||||
"""
|
||||
return provider_id(opts) not in ("", "off")
|
||||
|
||||
|
||||
def export_price_eur_kwh(opts: dict) -> float:
|
||||
"""AC4 / §9.3: the injection price is a SEPARATE SCALAR, not a second series.
|
||||
|
||||
@@ -1516,8 +1557,8 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
site condition, it is a plugin trying to read the markup, and turning it
|
||||
into "no price data" would hide the exact defect trap 1 exists to catch.
|
||||
"""
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
required_spread, uncertainty_of, widen_factor,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
@@ -1098,8 +1098,7 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
|
||||
@@ -265,9 +265,17 @@ options:
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
# static profile, plan provisional, NO ARBITRAGE.
|
||||
#
|
||||
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
|
||||
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
|
||||
# shipped default - the add-on fetches the feed every 30 minutes, validates
|
||||
# it, records the day in /data/price_history.json and reports what it holds
|
||||
# on the *Price history* status row. It changes NO energy decision: the cheap
|
||||
# window the maintenance cycle reads still comes from `static_profile`, and
|
||||
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
|
||||
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
|
||||
# whatsoever on a shipped install - the store was never even created.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
@@ -352,6 +360,12 @@ options:
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||
#
|
||||
# ⚠️ IT IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
|
||||
# above decides whether prices are FETCHED; this decides whether they move
|
||||
# energy. Leaving this off on a site with a provider configured is a
|
||||
# supported, documented state: prices are fetched, validated and recorded,
|
||||
# and nothing trades.
|
||||
#
|
||||
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||
# combinations that cannot work rather than letting them look armed. With
|
||||
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||
|
||||
@@ -1510,6 +1510,73 @@ check("when the feed answers again the observation replaces the kept plan and "
|
||||
and _t12_h.tariff_plan.from_feed is True
|
||||
and _t12_h.tariff_at != _t12_h_at)
|
||||
|
||||
# -- TARIFF-10 widened WHICH INSTALLS RUN THIS TASK, so the configurations it
|
||||
# -- newly keeps alive are graded here rather than reasoned about.
|
||||
# ⚠️ `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
# configuration validate.py ACCEPTS and TARIFF-10's `or` exists to protect. On
|
||||
# it `resolve_tariff()` returns `static_plan(provisional=False)` - the
|
||||
# CONFIGURED product, origin "static" - so `standing.from_feed` is false on
|
||||
# every refresh and this card's guard must never fire. Confirmed by driving it,
|
||||
# not by reading the origin table: a guard that quietly latched here would
|
||||
# freeze a static site's plan at its first window for the life of the install.
|
||||
_t12_i = derived(lambda: _T12Controller(
|
||||
{"price_provider": "off", "price_timeout_s": 10, "price_horizon_h": 48,
|
||||
"static_profile": STATIC_BE}).refresh_once())
|
||||
_t12_i_plan, _t12_i_at = _t12_i.tariff_plan, _t12_i.tariff_at
|
||||
_t12_i.refresh_once()
|
||||
check("PRECONDITION: a `price_provider: off` + static_profile site resolves to "
|
||||
"the CONFIGURED product, not to a §11.4 row - origin 'static', prices, "
|
||||
"and from_feed false because there is no feed to have observed",
|
||||
getattr(_t12_i_plan, "origin", None) == "static"
|
||||
and _t12_i_plan.has_prices is True and _t12_i_plan.from_feed is False)
|
||||
check("...and TARIFF-10 now keeps this task alive on exactly that site, where "
|
||||
"this card's guard must NEVER latch: the second refresh replaces the "
|
||||
"first and the timestamp moves, so the plan is regenerated for the "
|
||||
"current window every 30 minutes as it always was",
|
||||
_t12_i.tariff_plan is not _t12_i_plan
|
||||
and _t12_i.tariff_plan.origin == "static"
|
||||
and _t12_i.tariff_at != _t12_i_at)
|
||||
|
||||
# ⚠️ AND THE OTHER CONFIGURATION TARIFF-10 ADDED - a provider configured with
|
||||
# trading DISARMED - is graded by every driven check above without a second
|
||||
# fixture, because nothing on this path reads `arbitrage_enabled` at all. That
|
||||
# is a claim about the code rather than about a behaviour, so it is asserted as
|
||||
# one (habit 4's exception, FCST-06's precedent): a fixture that set the option
|
||||
# and asserted no difference would be comparing a thing against itself.
|
||||
_T12_TRADING_OPTS = ("arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
"arbitrage_soc_floor", "arbitrage_soc_ceiling")
|
||||
# ⚠️ AND IT IS WHAT THE FUNCTION READS THAT IS SCANNED, NOT ITS TEXT. Two
|
||||
# earlier drafts of this check were red against a CORRECT tree: the first
|
||||
# because the docstring paragraph that SAYS `arbitrage_enabled` is not read is
|
||||
# itself an occurrence of the string, the second because the log line says
|
||||
# "arbitrage, where it is armed, stops with it" - which is a sentence, not a
|
||||
# read. A substring test over a function grades its prose as much as its
|
||||
# behaviour (habit 4). So the symbols are taken from the AST, and the string
|
||||
# constants are taken separately and with the docstring dropped: naming
|
||||
# arbitrage in a message is fine, READING a trading option is the thing claimed
|
||||
# not to happen.
|
||||
_t12_syms = derived(lambda: {getattr(n, "id", None) or getattr(n, "attr", None)
|
||||
for n in ast.walk(_refresh)
|
||||
if isinstance(n, (ast.Name, ast.Attribute))})
|
||||
_t12_consts = derived(lambda: {c.value for b in _refresh.body[1:]
|
||||
for c in ast.walk(b)
|
||||
if isinstance(c, ast.Constant)
|
||||
and isinstance(c.value, str)})
|
||||
check("run_tariff_refresh() READS no trading option and calls nothing in "
|
||||
"app/arbitrage.py, so keeping a plan is a decision about what the add-on "
|
||||
"has LOOKED UP - TARIFF-10's own distinction, and why the checks above "
|
||||
"cover a provider-configured site whether or not arbitrage is armed",
|
||||
isinstance(_t12_syms, set)
|
||||
and not any("arbitrage" in (n or "") for n in _t12_syms)
|
||||
and not (_t12_consts & set(_T12_TRADING_OPTS))
|
||||
# ...and both scanners can see what IS there, or they grade nothing: the
|
||||
# one option this task does read, and a name it really does call.
|
||||
and "price_horizon_h" in _t12_consts
|
||||
and "fresh_tariff_plan" in _t12_syms
|
||||
# ...and the docstring really was dropped, or the first draft's defect is
|
||||
# back and the constant set is grading prose again.
|
||||
and not any("RETURNS, NEVER RAISES" in c for c in _t12_consts))
|
||||
|
||||
# -- the vocabulary, graded on its own so a mutation of it has a named home --
|
||||
check("TariffPlan.from_feed is true of exactly the observation - a §11.4 "
|
||||
"fallback, a no-data read and a CONFIGURED static profile are all false, "
|
||||
|
||||
@@ -51,7 +51,9 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from textwrap import dedent
|
||||
|
||||
@@ -67,6 +69,10 @@ from app.price import (
|
||||
volatility_regime,
|
||||
)
|
||||
from app import price as _price
|
||||
from app import arbitrage as _arb
|
||||
from app import main as _main
|
||||
from app.p1 import make_sample, split_signed
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
fails = []
|
||||
@@ -1945,6 +1951,660 @@ check("'off' is not refused, on any of the other configurations",
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. TARIFF-10 - a configured provider is FETCHED, and trading is still "
|
||||
"its own switch")
|
||||
# WHAT SHIPPED, AND WHY THIS SECTION IS NOT A CHECK ON AN `if`. `amain()` read
|
||||
#
|
||||
# if arbitrage.enabled(opts):
|
||||
# tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
#
|
||||
# `run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
# only caller of `price_history.carry()`, and `arbitrage_enabled` ships FALSE.
|
||||
# So on a shipped install with a provider configured NOTHING was fetched, no
|
||||
# plan was resolved, /data/price_history.json was never created, the *Price
|
||||
# history* row read `empty` for the life of the install, and TARIFF-09's
|
||||
# short-horizon warning could not fire because no fetch happened to warn about.
|
||||
# Measured over a 40 h two-instance soak: zero feed requests and
|
||||
# `present: false` across all 802 samples.
|
||||
#
|
||||
# ⚠️ A CHECK THAT PINS THE `if` WOULD NOT HAVE CAUGHT IT, because the `if` was
|
||||
# EXACTLY WHAT SOMEBODY MEANT TO WRITE. What was wrong is the SET OF
|
||||
# CONFIGURATIONS FROM WHICH A FETCH CAN BE REACHED, so that is what is graded:
|
||||
# the guards in `amain()` are EVALUATED against real option dicts (whatever they
|
||||
# are spelled as), the call graph under `app/` is walked to see whether the
|
||||
# tasks those guards start can arrive at `resolve_tariff` (wherever it has been
|
||||
# moved to), and then the fetch is DRIVEN and the requests counted, because a
|
||||
# reachability graph is an argument and a counted request is an observation.
|
||||
|
||||
|
||||
# -- the predicate the gate is built on, before anything is wired to it -------
|
||||
# ⚠️ ONE SPELLING, AND IT NOW HAS THREE READERS. `str(price_option(opts,
|
||||
# "price_provider") or "off").strip()` was written out TWICE - in
|
||||
# `load_prices()` and in `static_tariff.resolve_tariff()` - and main.py's
|
||||
# startup gate would have been a third. "Is a provider configured" is now a
|
||||
# DECISION rather than a detail, because it is what starts a background task,
|
||||
# so the three readers have to agree about the edge cases and `provider_id()`
|
||||
# is the one place they can. (`validate.py` keeps its own reader on purpose: it
|
||||
# validates the RAW options blob and must not route through the defaults.)
|
||||
check("`provider_configured()` is False on the shipped default, on an explicit "
|
||||
"'off', and on a key that is absent, empty or None - `price_option()` "
|
||||
"maps the last three onto the default and none of them may read as a "
|
||||
"configured feed",
|
||||
not any(_price.provider_configured(o) for o in
|
||||
({}, {"price_provider": "off"}, {"price_provider": ""},
|
||||
{"price_provider": None})))
|
||||
check("...and True on each shipped plugin id, so it is a predicate and not a "
|
||||
f"constant {sorted(PROVIDERS)}",
|
||||
all(_price.provider_configured({"price_provider": pid})
|
||||
for pid in PROVIDERS))
|
||||
# ⚠️ WHITESPACE IS THE EDGE THE `.strip()` IS FOR, AND NOTHING GRADED IT. The
|
||||
# Supervisor's `list(off|...)` schema cannot produce `" off "`, but
|
||||
# `validate_startup_config()` is also sim/inject.py's entry point and takes any
|
||||
# dict at all - and dropping the `.strip()` left all four price suites green
|
||||
# (measured, -8 B) until this pair was written.
|
||||
check("...and a padded id is normalised on both sides of the decision: "
|
||||
"' off ' is still off, and ' generic_dayahead ' is still that plugin",
|
||||
_price.provider_id({"price_provider": " off "}) == "off"
|
||||
and not _price.provider_configured({"price_provider": " off "})
|
||||
and _price.provider_id({"price_provider": " generic_dayahead "})
|
||||
== "generic_dayahead"
|
||||
and _price.provider_configured(
|
||||
{"price_provider": " generic_dayahead "}))
|
||||
check("...and `load_prices()` answers `no_data` for exactly the dicts "
|
||||
"`provider_configured()` calls unconfigured, so the startup gate and the "
|
||||
"fetch cannot disagree about what 'a provider is set' means",
|
||||
all((run(load_prices(o, datetime(2026, 1, 1, tzinfo=UTC),
|
||||
datetime(2026, 1, 2, tzinfo=UTC),
|
||||
transport=None)).kind == "no_data")
|
||||
== (not _price.provider_configured(o))
|
||||
for o in ({}, {"price_provider": "off"},
|
||||
{"price_provider": " off "},
|
||||
{"price_provider": "manual_csv",
|
||||
"price_csv_path": CSV_PATH})))
|
||||
|
||||
|
||||
def _coroutine_name(node):
|
||||
"""`controller.run_tariff_refresh()` -> `run_tariff_refresh`."""
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
return node.func.attr
|
||||
if isinstance(node.func, ast.Name):
|
||||
return node.func.id
|
||||
return ast.unparse(node)
|
||||
|
||||
|
||||
def _task_sites(node, guards=()):
|
||||
"""Every `create_task(...)` under `node`, with the `if`s that guard it.
|
||||
|
||||
The guard is kept as SOURCE and evaluated later against an options dict, so
|
||||
this reports what the tree says rather than what any one ticket expected to
|
||||
find there. An `else` branch is recorded as `not (<test>)`.
|
||||
"""
|
||||
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 += _task_sites(stmt, guards + (test,))
|
||||
for stmt in child.orelse:
|
||||
out += _task_sites(stmt, guards + (f"not ({test})",))
|
||||
continue
|
||||
if isinstance(child, ast.Call) and \
|
||||
ast.unparse(child.func).endswith("create_task"):
|
||||
if child.args:
|
||||
out.append((guards, _coroutine_name(child.args[0])))
|
||||
continue
|
||||
out += _task_sites(child, guards)
|
||||
return out
|
||||
|
||||
|
||||
def _call_graph(app_dir):
|
||||
"""name -> the names it calls, over every module in `app/`.
|
||||
|
||||
⚠️ IT IS KEYED BY NAME AND THEREFORE OVER-APPROXIMATES, AND THAT IS THE
|
||||
SAFE DIRECTION HERE. Two functions called `run` in two modules are one node;
|
||||
a method call on an unrelated object joins the same node. So a `reachable`
|
||||
answer is an upper bound and a `NOT reachable` answer is the strong one -
|
||||
which is why every positive row below is also DRIVEN, with the requests
|
||||
counted, rather than being left to this graph (MUTATION.md habit 13).
|
||||
"""
|
||||
graph = {}
|
||||
for name in sorted(os.listdir(app_dir)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
tree = ast.parse(open(os.path.join(app_dir, name),
|
||||
encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
called = set()
|
||||
for inner in ast.walk(node):
|
||||
if isinstance(inner, ast.Call):
|
||||
fn = inner.func
|
||||
if isinstance(fn, ast.Attribute):
|
||||
called.add(fn.attr)
|
||||
elif isinstance(fn, ast.Name):
|
||||
called.add(fn.id)
|
||||
graph.setdefault(node.name, set()).update(called)
|
||||
return graph
|
||||
|
||||
|
||||
def _reaches(graph, start, target):
|
||||
"""Can `start` arrive at `target` through the call graph?"""
|
||||
seen, stack = set(), [start]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur in seen:
|
||||
continue
|
||||
seen.add(cur)
|
||||
for nxt in graph.get(cur, ()):
|
||||
if nxt == target:
|
||||
return True
|
||||
stack.append(nxt)
|
||||
return False
|
||||
|
||||
|
||||
def _started(sites, opts):
|
||||
"""[(coroutine, guards, verdict)] for `opts`. Verdict True/False/'?'.
|
||||
|
||||
`'?'` is a guard this cannot decide from the options alone - `p1_source is
|
||||
not None` is one - and it is reported rather than assumed either way. A
|
||||
fetch-reaching task whose guard is undecidable would make every row below
|
||||
vacuous, so that is a check of its own.
|
||||
"""
|
||||
out = []
|
||||
for guards, name in sites:
|
||||
verdict = True
|
||||
for guard in guards:
|
||||
try:
|
||||
answer = bool(eval(guard, vars(_main), {"opts": opts}))
|
||||
except NameError:
|
||||
verdict = "?"
|
||||
break
|
||||
if not answer:
|
||||
verdict = False
|
||||
break
|
||||
out.append((name, guards, verdict))
|
||||
return out
|
||||
|
||||
|
||||
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
|
||||
encoding="utf-8").read()))
|
||||
_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "amain"))
|
||||
_SITES = derived(lambda: _task_sites(_AMAIN))
|
||||
_GRAPH = derived(lambda: _call_graph(_APP))
|
||||
_FETCH_ENTRY = "resolve_tariff"
|
||||
|
||||
print(f" create_task sites found in amain(): "
|
||||
f"{[(n, list(g)) for g, n in _SITES]}")
|
||||
print(f" call graph: {len(_GRAPH)} named functions across app/")
|
||||
print(" ⚠️ what this instrument CANNOT see, largest hole first: a coroutine "
|
||||
"started by anything other than `create_task` (gather, ensure_future, a "
|
||||
"thread, a timer); a guard that is not an enclosing `if` in amain() (a "
|
||||
"while, a try/except, an early return above the site, or a refusal "
|
||||
"INSIDE the coroutine); any module outside app/; and - because the graph "
|
||||
"is keyed by NAME - two same-named functions as one node, which makes "
|
||||
"every `reachable` answer an UPPER bound.")
|
||||
|
||||
# -- the instrument is calibrated before anything is graded with it (habit 1) --
|
||||
check(f"the scanner finds amain()'s create_task sites at all - it found "
|
||||
f"{len(_SITES) if not isinstance(_SITES, _Raised) else _SITES}, and at "
|
||||
f"zero every row below would be vacuously true",
|
||||
not isinstance(_SITES, _Raised) and len(_SITES) >= 2)
|
||||
check(f"...and the call graph can tell reaching from not-reaching: "
|
||||
f"`run_tariff_refresh` arrives at {_FETCH_ENTRY!r} and `run_control` - "
|
||||
f"the control tick, which must never await a fetch - does not. A graph "
|
||||
f"that answered the same for both would pass every row below however the "
|
||||
f"gate was wired",
|
||||
_reaches(_GRAPH, "run_tariff_refresh", _FETCH_ENTRY)
|
||||
and not _reaches(_GRAPH, "run_control", _FETCH_ENTRY))
|
||||
|
||||
|
||||
def _fetchers(opts):
|
||||
"""The coroutines amain() STARTS, for `opts`, that can reach the feed."""
|
||||
return sorted(n for n, _, v in _started(_SITES, opts)
|
||||
if v is True and _reaches(_GRAPH, n, _FETCH_ENTRY))
|
||||
|
||||
|
||||
def _undecidable_fetchers(opts):
|
||||
return sorted(n for n, _, v in _started(_SITES, opts)
|
||||
if v == "?" and _reaches(_GRAPH, n, _FETCH_ENTRY))
|
||||
|
||||
|
||||
# The four configurations. `_T10_BASE` carries no price keys at all, so the
|
||||
# shipped defaults in PRICE_OPTION_DEFAULTS are what answer - which is the
|
||||
# install the defect was measured on.
|
||||
_T10_BASE = {"setpoint_entity": "number.sp", "max_w": 5000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000}
|
||||
_T10_PROFILE = json.dumps({"periods": [
|
||||
{"name": "night", "days": "all", "from": "22:00", "to": "07:00",
|
||||
"eur_per_kwh": 0.20},
|
||||
{"name": "day", "days": "all", "from": "07:00", "to": "22:00",
|
||||
"eur_per_kwh": 0.284}]})
|
||||
_T10_CASES = (
|
||||
("shipped default: no provider, no trading", {}, False),
|
||||
("PROVIDER configured, trading OFF (the defect)",
|
||||
{"price_provider": "generic_dayahead"}, True),
|
||||
("PROVIDER configured, trading ON",
|
||||
{"price_provider": "generic_dayahead", "arbitrage_enabled": True}, True),
|
||||
("no provider, trading ON on a static profile",
|
||||
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}, True),
|
||||
)
|
||||
print(" startup reachability of resolve_tariff(), by configuration:")
|
||||
for _label, _over, _want in _T10_CASES:
|
||||
print(f" {'reachable' if _fetchers(dict(_T10_BASE, **_over)) else '- '}"
|
||||
f" {_label}")
|
||||
|
||||
check(f"the shipped defaults really do leave `arbitrage_enabled` off "
|
||||
f"({PRICE_OPTION_DEFAULTS['arbitrage_enabled']!r}) and `price_provider` "
|
||||
f"off ({PRICE_OPTION_DEFAULTS['price_provider']!r}) - the precondition "
|
||||
f"every row below varies from, and if a later card changes either "
|
||||
f"default these rows stop meaning what they say. Section 1 is what pins "
|
||||
f"this dict to config.yaml",
|
||||
PRICE_OPTION_DEFAULTS["price_provider"] == "off"
|
||||
and PRICE_OPTION_DEFAULTS["arbitrage_enabled"] is False
|
||||
and not _arb.enabled(dict(_T10_BASE))
|
||||
and not _price.provider_configured(dict(_T10_BASE)))
|
||||
check(f"AC1: with a `price_provider` set and `arbitrage_enabled` at its shipped "
|
||||
f"default, the startup path REACHES the feed "
|
||||
f"{_fetchers(dict(_T10_BASE, price_provider='generic_dayahead'))}. This "
|
||||
f"is the shipped defect: it read `[]`, so nothing was ever fetched",
|
||||
_fetchers(dict(_T10_BASE, price_provider="generic_dayahead")) != [])
|
||||
check(f"AC2: with `price_provider: off` and trading off - the true shipped "
|
||||
f"default - NO task reaches the feed "
|
||||
f"{_fetchers(dict(_T10_BASE))}, so an install that asked for no prices "
|
||||
f"acquires no periodic work from this card",
|
||||
_fetchers(dict(_T10_BASE)) == [])
|
||||
check("...and the guard on the fetch-reaching task is decidable from the "
|
||||
"OPTIONS alone. A gate moved onto a local variable would make every row "
|
||||
"in this section vacuous rather than red",
|
||||
_undecidable_fetchers(dict(_T10_BASE, price_provider="generic_dayahead"))
|
||||
== [])
|
||||
# ⚠️ THE `or` IS LOAD-BEARING AND THIS IS THE ROW THAT SAYS SO. `price_provider:
|
||||
# off` + a `static_profile` + `arbitrage_enabled: true` is a configuration
|
||||
# validate.py ACCEPTS and that trades (measured: ARBITRAGE_CHARGE -5000 W at
|
||||
# `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile). Gating
|
||||
# the refresh on the provider ALONE would leave that site's `tariff_plan` at
|
||||
# None forever and silently disarm a strategy the UI says is running.
|
||||
_T10_OLD = {lbl: _arb.enabled(dict(_T10_BASE, **over))
|
||||
for lbl, over, _ in _T10_CASES}
|
||||
_T10_NEW = {lbl: bool(_fetchers(dict(_T10_BASE, **over)))
|
||||
for lbl, over, _ in _T10_CASES}
|
||||
print(f" old gate (arbitrage.enabled alone) -> {list(_T10_OLD.values())}")
|
||||
print(f" shipped gate -> {list(_T10_NEW.values())}")
|
||||
check(f"the gate is a STRICT WIDENING: every configuration that started the "
|
||||
f"refresh before still starts it, so nothing was taken away from a live "
|
||||
f"install {[l for l in _T10_OLD if _T10_OLD[l] and not _T10_NEW[l]]}",
|
||||
all(_T10_NEW[l] for l in _T10_OLD if _T10_OLD[l]))
|
||||
check("...and it is a widening rather than a no-op: at least one configuration "
|
||||
"fetches now that did not before, which is the whole ticket",
|
||||
any(_T10_NEW[l] and not _T10_OLD[l] for l in _T10_OLD))
|
||||
|
||||
|
||||
# -- and the fetch is DRIVEN, because a graph is an argument and a counted ----
|
||||
# -- request is an observation (habit 4) -------------------------------------
|
||||
_CAPTURE = derived(lambda: json.load(open(
|
||||
os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"),
|
||||
encoding="utf-8")))
|
||||
|
||||
|
||||
def _restamped_body(now):
|
||||
"""The COMMITTED capture, with only its instants moved onto `now`.
|
||||
|
||||
⚠️ THE SHAPE AND THE PRICES ARE THE CAPTURE'S (TARIFF-08, habit 19). What
|
||||
is synthetic is the CLOCK and nothing else: `run_tariff_refresh` reads the
|
||||
wall clock and there is no seam to inject one, so a 2026-09-09 capture
|
||||
would fall outside every horizon it asks for. Section 2b replays the
|
||||
capture unmodified against the parser; this one is grading the WIRING.
|
||||
"""
|
||||
base = int(now.timestamp()) // 3600 * 3600
|
||||
doc = dict(_CAPTURE)
|
||||
doc["unix_seconds"] = [base + 900 * i
|
||||
for i in range(len(_CAPTURE["price"]))]
|
||||
return json.dumps(doc).encode()
|
||||
|
||||
|
||||
class _T10Hass:
|
||||
async def limits(self, entity):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 0.0, "sensor.soc": 55.0,
|
||||
"sensor.batt": 0.0}.get(entity)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
class _T10Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, key):
|
||||
return None
|
||||
|
||||
|
||||
class _T10Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _drive_startup(over):
|
||||
"""Start exactly what amain() would start for `opts`, and watch the wire.
|
||||
|
||||
⚠️ THE TASK IS STARTED ONLY IF THE SHIPPED GUARD SAYS SO. That is the whole
|
||||
point: the coroutine is not called because this file knows its name, it is
|
||||
called because `_fetchers()` evaluated amain()'s own guard against these
|
||||
options. A gate that goes back to `arbitrage.enabled(opts)` leaves the
|
||||
coroutine uncalled and the request count at zero.
|
||||
|
||||
⚠️ AND IT RUNS UNDER A DEADLINE (habit 5). The refresh loop is infinite by
|
||||
design; the failure mode of a mutation that never resolves a plan is a HANG,
|
||||
which reads like flaky infrastructure rather than like a named check.
|
||||
"""
|
||||
opts = dict(_T10_BASE, **over)
|
||||
tmp = tempfile.mkdtemp(prefix="tariff10-")
|
||||
_tmpdirs_t10.append(tmp)
|
||||
store_path = os.path.join(tmp, "price_history.json")
|
||||
seen = []
|
||||
|
||||
async def _counting_get(url, timeout_s):
|
||||
seen.append(url)
|
||||
return _restamped_body(datetime.now(timezone.utc))
|
||||
|
||||
real_get = _price.https_get
|
||||
_price.https_get = _counting_get
|
||||
try:
|
||||
async def _go():
|
||||
stop = asyncio.Event()
|
||||
ctl = _main.Controller(opts, _T10Hass(), _T10Store(), _T10Pub(),
|
||||
stop=stop,
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=store_path)
|
||||
names = _fetchers(opts)
|
||||
if "run_tariff_refresh" not in names:
|
||||
# Nothing amain() starts reaches the feed. Anything ELSE that
|
||||
# does is a task this harness cannot drive, and it is a FAILURE
|
||||
# rather than a shrug - see the check below.
|
||||
return ctl, names
|
||||
task = asyncio.create_task(ctl.run_tariff_refresh())
|
||||
for _ in range(250): # ~5 s ceiling
|
||||
if ctl.tariff_at is not None:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
ctl.stopping = True
|
||||
stop.set()
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
return ctl, names
|
||||
ctl, names = asyncio.run(_go())
|
||||
finally:
|
||||
_price.https_get = real_get
|
||||
try:
|
||||
n_points = len(list(ctl.tariff_plan.read.points))
|
||||
except Exception: # noqa: BLE001 - Absence
|
||||
n_points = 0
|
||||
return {"requests": seen, "started": names,
|
||||
"store_present": os.path.exists(store_path),
|
||||
"points": n_points,
|
||||
"history": ctl.price_history.words}
|
||||
|
||||
|
||||
_tmpdirs_t10 = []
|
||||
_on = derived(lambda: _drive_startup({"price_provider": "generic_dayahead"}))
|
||||
_off = derived(lambda: _drive_startup({}))
|
||||
_static_armed = derived(lambda: _drive_startup(
|
||||
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}))
|
||||
print(f" provider + trading OFF -> {len(_on['requests'])} request(s), "
|
||||
f"store written {_on['store_present']}, {_on['points']} point plan, "
|
||||
f"Price history row: {_on['history'][:70]!r}")
|
||||
print(f" no provider -> {len(_off['requests'])} request(s), "
|
||||
f"store written {_off['store_present']}, "
|
||||
f"Price history row: {_off['history'][:40]!r}")
|
||||
|
||||
check(f"AC1, DRIVEN: a shipped-default install with a provider configured "
|
||||
f"actually ASKS THE FEED - {len(_on['requests'])} request(s), "
|
||||
f"{_on['requests'][:1]}. The soak measured ZERO here over 40 hours",
|
||||
len(_on["requests"]) >= 1)
|
||||
check(f"...and a plan RESOLVES from it: {_on['points']} points, against the "
|
||||
f"{len(_CAPTURE['price'])} the capture publishes",
|
||||
_on["points"] == len(_CAPTURE["price"]))
|
||||
check(f"...and /data/price_history.json is WRITTEN - present={_on['store_present']}. "
|
||||
f"The soak measured `present: false` on all 802 samples",
|
||||
_on["store_present"] is True)
|
||||
check(f"...and the *Price history* status row POPULATES rather than reading "
|
||||
f"`empty` forever: {_on['history'][:60]!r}",
|
||||
isinstance(_on["history"], str)
|
||||
and not _on["history"].startswith("empty")
|
||||
and not _on["history"].startswith("unavailable"))
|
||||
check(f"AC2, DRIVEN: `price_provider: off` issues NO request and creates no "
|
||||
f"store - {len(_off['requests'])} request(s), "
|
||||
f"present={_off['store_present']}",
|
||||
_off["requests"] == [] and _off["store_present"] is False)
|
||||
check(f"...and neither does the one configuration that keeps its task without "
|
||||
f"a provider - trading armed on a static profile makes "
|
||||
f"{len(_static_armed['requests'])} request(s), because "
|
||||
f"`resolve_tariff()` answers a provider-off site out of the static "
|
||||
f"profile without opening a socket. NOBODY acquires a periodic HTTPS "
|
||||
f"call from this card",
|
||||
_static_armed["requests"] == []
|
||||
and "run_tariff_refresh" in _static_armed["started"])
|
||||
check(f"...and no OTHER startup task can reach the feed - if one appears, this "
|
||||
f"harness cannot drive it and every driven row above stops covering the "
|
||||
f"startup path {_on['started']}",
|
||||
_on["started"] == ["run_tariff_refresh"])
|
||||
|
||||
|
||||
# -- AC3, and it is the property this card is most dangerous to: ARBITRAGE -----
|
||||
# -- OFF STILL MEANS NO TRADE, EVER. Graded by DRIVING `Controller.cycle()` ---
|
||||
# -- with a provider plan resolved - the state TARIFF-10 newly creates - and ---
|
||||
# -- moving exactly one option (habit 7). ------------------------------------
|
||||
#
|
||||
# ⚠️ THE BASELINE IS THE HALF THAT MAKES THIS A CHECK. "No trade" is satisfied
|
||||
# by a fixture in which nothing could have traded anyway - a flat series, a SoC
|
||||
# outside the bounds, a peak guard that never ran - and a check like that stays
|
||||
# green however the gate is wired. So the SAME plan, the SAME SoC, the SAME P1
|
||||
# history and the SAME threshold are run with `arbitrage_enabled: true`, and
|
||||
# that run must COMMAND THE BATTERY. If the armed row stops trading, the
|
||||
# unarmed row stops being evidence and both go red together.
|
||||
|
||||
|
||||
def _t10_sample(when, net_w):
|
||||
"""One P1 telegram through the shipped derivation."""
|
||||
imp, exp = split_signed(net_w)
|
||||
return make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=when, ingest_mono=when.timestamp())
|
||||
|
||||
|
||||
class _T10TradeHass(_T10Hass):
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
return True
|
||||
|
||||
|
||||
def _t10_plan(now, cheap_now):
|
||||
"""A 24 h FIRM series whose cheapest hour is now, or whose dearest is.
|
||||
|
||||
Firm, because refusal 3 drops provisional hours from the trade set and a
|
||||
plan of carried hours cannot trade whatever the gate says - which would
|
||||
make the armed baseline below vacuous exactly as a flat series would.
|
||||
"""
|
||||
top = now.replace(minute=0, second=0, microsecond=0)
|
||||
lo, hi = 0.05, 0.60
|
||||
return TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(start=top + timedelta(hours=i),
|
||||
eur_per_kwh=(lo if (i < 3) == cheap_now else hi),
|
||||
provisional=False)
|
||||
for i in range(24)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", now),
|
||||
None, "provider")
|
||||
|
||||
|
||||
def _t10_trade_cycle(armed, cheap_now):
|
||||
"""One real `Controller.cycle()` on a resolved provider plan."""
|
||||
tmp = tempfile.mkdtemp(prefix="tariff10t-")
|
||||
_tmpdirs_t10.append(tmp)
|
||||
opts = dict(_T10_BASE, meter_source="ha_dsmr", meter_entity="sensor.grid",
|
||||
heartbeat_s=10, price_provider="generic_dayahead",
|
||||
cycle_cost_threshold_eur=0.10, arbitrage_enabled=armed)
|
||||
|
||||
async def _go():
|
||||
hass = _T10TradeHass()
|
||||
ctl = _main.Controller(opts, hass, _T10Store(), _T10Pub(),
|
||||
stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
ctl.auto = True
|
||||
ctl.dev_min, ctl.dev_max = -5000.0, 5000.0
|
||||
now = datetime.now(timezone.utc)
|
||||
# ⚠️ A WHOLE QUARTER OF TELEGRAMS, AND FIVE MINUTES IS NOT ENOUGH.
|
||||
# Without any, `p1_enabled` is on, `inputs_ok` is False and cycle()
|
||||
# returns before it reaches the strategy at all. With only five
|
||||
# minutes, whether the quarter is COVERED depends on where the wall
|
||||
# clock happens to sit inside it: run late in a quarter and the guard
|
||||
# is blind, engages conservatively, and `decide()` refuses for §9.1's
|
||||
# reason instead of trading - which would make the armed baseline
|
||||
# below vacuous on some runs and not others. Measured: the same four
|
||||
# rows came back `peak guard is holding` at one time of day and
|
||||
# `ARBITRAGE_CHARGE` at another. 900 s at the shipped 5 s telegram
|
||||
# cadence covers any quarter from any starting phase.
|
||||
for k in range(181):
|
||||
ctl.p1.submit(_t10_sample(now - timedelta(seconds=5 * (180 - k)),
|
||||
300.0))
|
||||
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
|
||||
await ctl.read_inputs()
|
||||
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
|
||||
await ctl.cycle()
|
||||
return ctl, hass
|
||||
ctl, hass = asyncio.run(_go())
|
||||
return {"arb": None if ctl.arb is None else ctl.arb.action,
|
||||
"target": ctl.target, "reason": ctl.reason,
|
||||
"writes": hass.writes,
|
||||
"guard": None if ctl.peak_guard is None else ctl.peak_guard.engaged}
|
||||
|
||||
|
||||
_T10_TRADE = derived(lambda: {(a, c): _t10_trade_cycle(a, c)
|
||||
for a in (False, True) for c in (True, False)})
|
||||
for _key in sorted(_T10_TRADE, key=str):
|
||||
_r = _T10_TRADE[_key]
|
||||
print(f" arbitrage_enabled={str(_key[0]):5s} cheapest hour is now="
|
||||
f"{str(_key[1]):5s} -> arb={str(_r['arb']):16s} "
|
||||
f"commanded {_r['target']:+.0f} W, reason {_r['reason'][:40]!r}")
|
||||
|
||||
# ⚠️ THE PRECONDITION, AND IT IS THE MASKING HAZARD IN THIS FIXTURE (habit 1).
|
||||
# `decide()` refuses outright while the peak guard is engaged, §9.1 outranking
|
||||
# §10.1 - so a fixture whose guard is holding answers "no trade" in EVERY row,
|
||||
# the armed one included, and the AC3 rows below would grade the guard rather
|
||||
# than the gate. It is asserted, not assumed.
|
||||
check(f"the peak guard is CLEAR in all four rows, so what refuses a trade "
|
||||
f"below is the arbitrage gate and not §9.1's interlock "
|
||||
f"{ {k: _T10_TRADE[k]['guard'] for k in sorted(_T10_TRADE, key=str)} }",
|
||||
all(_T10_TRADE[k]["guard"] is False for k in _T10_TRADE))
|
||||
check("the ARMED baseline actually trades on the plan TARIFF-10 now resolves - "
|
||||
"it charges in the cheap hour and discharges in the dear one. Without "
|
||||
"this row, 'trading off never trades' would be satisfied by a fixture "
|
||||
"that could not have traded either way",
|
||||
_T10_TRADE[(True, True)]["arb"] == "ARBITRAGE_CHARGE"
|
||||
and _T10_TRADE[(True, False)]["arb"] == "ARBITRAGE_DISCHARGE")
|
||||
check(f"...and the site is actually COMMANDED there, which is the only "
|
||||
f"observable that moves energy: {_T10_TRADE[(True, True)]['target']:+.0f} W "
|
||||
f"on the cheap hour against "
|
||||
f"{_T10_TRADE[(True, False)]['target']:+.0f} W on the dear one",
|
||||
_T10_TRADE[(True, True)]["target"] < -1000.0
|
||||
and _T10_TRADE[(True, False)]["target"] > 1000.0)
|
||||
check(f"AC3: with `arbitrage_enabled: false` and the SAME resolved provider "
|
||||
f"plan, the strategy is never consulted and NOTHING trades - "
|
||||
f"arb={_T10_TRADE[(False, True)]['arb']!r} / "
|
||||
f"{_T10_TRADE[(False, False)]['arb']!r} in both directions",
|
||||
_T10_TRADE[(False, True)]["arb"] is None
|
||||
and _T10_TRADE[(False, False)]["arb"] is None)
|
||||
check(f"...and the command is the LOOP's, not a trade that merely went "
|
||||
f"unnamed: {_T10_TRADE[(False, True)]['target']:+.0f} W / "
|
||||
f"{_T10_TRADE[(False, False)]['target']:+.0f} W, reason "
|
||||
f"{_T10_TRADE[(False, True)]['reason'][:30]!r} - the same small "
|
||||
f"self-consumption figure on both plans, where the armed rows differ by "
|
||||
f"10 kW between them",
|
||||
all(abs(_T10_TRADE[(False, c)]["target"]) < 1000.0
|
||||
and "arbitrage" not in _T10_TRADE[(False, c)]["reason"]
|
||||
for c in (True, False)))
|
||||
check("...and the trading gate is untouched by this card: it is still "
|
||||
"`arbitrage.enabled()` and it is still the only thing the price "
|
||||
"strategy is gated on - the four rows above differ in that option and "
|
||||
"in nothing else",
|
||||
_arb.enabled({"arbitrage_enabled": True})
|
||||
and not _arb.enabled({})
|
||||
and not _arb.enabled({"price_provider": "generic_dayahead"}))
|
||||
|
||||
|
||||
# -- AC4: the four surfaces that DESCRIBE this, pinned as a PAIR with it ------
|
||||
# ⚠️ THIS IS A TEXT CHECK AND IT IS WEAK ON PURPOSE (test_translations.py §3's
|
||||
# argument). It cannot know a sentence is TRUE. What it CAN do is refuse to let
|
||||
# the code and the prose move independently: the left side of each pairing is
|
||||
# DRIVEN above, the right side is read off the file, and the two must agree. It
|
||||
# shipped as False against True - the docs described Option A for as long as
|
||||
# both existed - and an overclaim in a doc is a defect in this repository.
|
||||
_T10_DOCS = derived(lambda: open(os.path.join(HERE, "DOCS.md"),
|
||||
encoding="utf-8").read())
|
||||
_T10_CFG = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
||||
encoding="utf-8").read())
|
||||
_T10_TR = derived(lambda: {lang: open(os.path.join(HERE, "translations",
|
||||
f"{lang}.yaml"),
|
||||
encoding="utf-8").read()
|
||||
for lang in ("en", "nl")})
|
||||
_T10_FETCHES = bool(_on["requests"])
|
||||
_T10_DOCS_SAYS = "selecting a provider is what starts the fetch" \
|
||||
in _T10_DOCS.lower()
|
||||
check(f"AC4: the BEHAVIOUR and DOCS.md agree about what a configured provider "
|
||||
f"does on a shipped-default install - it fetches = {_T10_FETCHES}, DOCS "
|
||||
f"says it fetches = {_T10_DOCS_SAYS}. These two are the pair that has to "
|
||||
f"move together",
|
||||
_T10_FETCHES == _T10_DOCS_SAYS)
|
||||
# The claim that had rotted in the OTHER direction: OPT-04 shipped the
|
||||
# consumer, so "nothing consumes the price series yet" had been false for a
|
||||
# release before this card made the first half false too.
|
||||
_T10_STALE = {"config.yaml": "consumes\n # the series yet" in _T10_CFG
|
||||
or "nothing consumes the price series" in _T10_CFG,
|
||||
"en.yaml": "nothing consumes the price series yet"
|
||||
in _T10_TR["en"],
|
||||
"nl.yaml": "er is nog niets dat de prijsreeks gebruikt"
|
||||
in _T10_TR["nl"]}
|
||||
check(f"...and no surface still says the price series has no consumer - untrue "
|
||||
f"since OPT-04, and it sat beside the claim this card is fixing in all "
|
||||
f"three files {[k for k, v in _T10_STALE.items() if v]}",
|
||||
not any(_T10_STALE.values()))
|
||||
check("...and BOTH translations tell an operator which switch does which - "
|
||||
"the provider option says it is what starts the fetch, and the trading "
|
||||
"option says turning it off does not turn the feed off. One language "
|
||||
"updated and not the other is how this surface rots",
|
||||
"STARTS THE PRICE FETCH" in _T10_TR["en"]
|
||||
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
|
||||
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
|
||||
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
for _d in _tmpdirs_t10:
|
||||
shutil.rmtree(_d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
|
||||
@@ -143,7 +143,7 @@ configuration:
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
|
||||
@@ -176,7 +176,7 @@ configuration:
|
||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
||||
arbitrage_enabled:
|
||||
name: "Tariff · Trade on the price spread"
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
||||
|
||||
@@ -123,7 +123,7 @@ configuration:
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
@@ -156,7 +156,7 @@ configuration:
|
||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
||||
arbitrage_enabled:
|
||||
name: "Tarief · Handelen op het prijsverschil"
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
||||
|
||||
Reference in New Issue
Block a user