PRICE-01: price_service provider, fallback chain, and the gate read from it
A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.
The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.
No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.
The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude
parent
0ce7d35f80
commit
9ac3a9d2bc
@@ -0,0 +1,95 @@
|
|||||||
|
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
||||||
|
|
||||||
|
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
||||||
|
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
||||||
|
card answers for one provider. Design: the owner's price service note of
|
||||||
|
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
||||||
|
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
||||||
|
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
||||||
|
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **The host is a constant in the plugin**, declared as its only egress host,
|
||||||
|
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
||||||
|
*Cost:* every client talks to the one host, and moving the server is a new
|
||||||
|
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
||||||
|
TLS, and a token per client that the server can revoke.
|
||||||
|
2. **The chain is server, then energy-charts, then the saved prices**
|
||||||
|
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
||||||
|
server that answers with old prices is skipped like one that is down. A step
|
||||||
|
down and a step up are each one log line, written when the step changes. The
|
||||||
|
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
||||||
|
asked for its own feed.
|
||||||
|
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
||||||
|
already keeps (the last three Brussels days of what was published). They are
|
||||||
|
the all-in prices as stored; a change of markup shows from the next fetch
|
||||||
|
that works. A read from the store is not filed again.
|
||||||
|
4. **No price for now** means no published price covers the present moment. Not
|
||||||
|
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
||||||
|
published on any healthy day, and carried hours are already marked estimated.
|
||||||
|
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
||||||
|
and one notification (*No prices*, English and Dutch), taken down when a
|
||||||
|
price returns. A start-up before the first refresh finished is not a gap.
|
||||||
|
The notification is not re-asserted after a Home Assistant core restart
|
||||||
|
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
||||||
|
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
||||||
|
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
||||||
|
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
||||||
|
run when the *current 15-minute price is at or below today's average*, today
|
||||||
|
being the Brussels day, computed from the provider's prices. This is the rule
|
||||||
|
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
||||||
|
the pair to the provider does not change when the maintenance charge runs.
|
||||||
|
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
||||||
|
hours of the whole plan). This replaces, for this provider only,
|
||||||
|
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
||||||
|
writers: the pair beside a provider stays refused; `static_profile` beside
|
||||||
|
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
||||||
|
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
||||||
|
did. (3) The basis is the one above, not `cheap_now()`.
|
||||||
|
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
||||||
|
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
||||||
|
never put in a message by the plugin; taken out of any reason text by
|
||||||
|
`price.scrub()`; the real transport reports only a status or an exception's
|
||||||
|
name. validate.py refuses `price_service` with no token, and a token with a
|
||||||
|
space or line break.
|
||||||
|
|
||||||
|
## Where the gate equals the Home Assistant pair, and where it does not
|
||||||
|
|
||||||
|
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
||||||
|
rules in `price_service.yaml` (price at the quarter, average of today's
|
||||||
|
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
||||||
|
clock-change days, at three instants in every period, and through a real
|
||||||
|
`Controller` for a whole day. Every answer is the same.
|
||||||
|
|
||||||
|
Not equal, by design or by arithmetic:
|
||||||
|
|
||||||
|
- The comparison is made on all-in prices. With a flat taxes figure and a
|
||||||
|
markup and taxes of at most five decimals the offset cancels and the answer
|
||||||
|
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
||||||
|
prices differ by hour, so the answer can differ from the wholesale comparison
|
||||||
|
the HA sensors made. A site on that profile should check before moving.
|
||||||
|
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
||||||
|
have more than five decimals.
|
||||||
|
- The plan is retired when no refresh has succeeded for 90 minutes
|
||||||
|
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
||||||
|
answer after its REST sensor stopped updating was not measured here.
|
||||||
|
- The average is over the firm prices the provider holds for today. After a
|
||||||
|
restart in the middle of a day with only the saved part of today, it is the
|
||||||
|
average of that part.
|
||||||
|
|
||||||
|
## Not covered
|
||||||
|
|
||||||
|
- **No real answer of the server was seen.** No token was available. The
|
||||||
|
layout is the design's; `test_price_service.py` uses a mock and, for the
|
||||||
|
parser, the real energy-charts capture reshaped with the design's extra keys.
|
||||||
|
The first capture from the live server (rig test) should replace the mock.
|
||||||
|
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
||||||
|
30 minutes, and at most two requests when the server fails.
|
||||||
|
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
||||||
|
- The `price_service` REST and template sensors in Home Assistant are not
|
||||||
|
touched; they stop being read when the pair is cleared.
|
||||||
@@ -222,6 +222,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
|||||||
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||||
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
||||||
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
||||||
|
| Tariff | Price service token | `price_service_token` | For the price service only. The token you were given for it. Leave it empty with any other price source. |
|
||||||
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
||||||
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
|
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
|
||||||
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
|
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
|
||||||
@@ -290,6 +291,7 @@ When the add-on refuses to start, each reason names the setting by its label and
|
|||||||
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
||||||
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
|
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
|
||||||
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
|
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
|
||||||
|
- **Price service token** (`price_service_token`): a password field, shown masked on the Configuration tab and never drawn on the add-on page. It is needed with `price_provider: price_service`, and the add-on refuses to start without it. It never appears in the log, the status page, a refusal or a notification. See [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||||
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
|
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
|
||||||
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
|
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
|
||||||
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
|
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
|
||||||
@@ -2198,20 +2200,24 @@ does, and only after you have read why.
|
|||||||
> `static_profile` below needs no network at all, so its cheap window is
|
> `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
|
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
> 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,
|
> that window, with one exception.** A provider-backed plan exists on every
|
||||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
> configured site, but *Cheap window* and the maintenance cycle's grid top-up
|
||||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
> read `static_profile` and nothing else — a deliberate limit, recorded with
|
||||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
> what it would take to change in
|
||||||
|
> `DECISIONS/TARIFF-10-fetching-is-not-trading.md`. The exception is
|
||||||
|
> `price_service` (PRICE-01): with it, the window comes from the provider's own
|
||||||
|
> prices, as described under [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `price_provider` | `off` (default) or one of the three plugins below |
|
| `price_provider` | `off` (default) or one of the plugins below |
|
||||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||||
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
||||||
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
||||||
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
||||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||||||
|
| `price_service_token` | The token for `price_service`. A password field. Empty with any other source |
|
||||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||||
| `price_timeout_s` | Per-request timeout |
|
| `price_timeout_s` | Per-request timeout |
|
||||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||||
@@ -2224,6 +2230,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
|
|||||||
| plugin | product | the published figure contains | the add-on adds |
|
| plugin | product | the published figure contains | the add-on adds |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||||
|
| `price_service` | day-ahead | energy only (wholesale, from your own price server) | markup **and** taxes |
|
||||||
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
||||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||||
|
|
||||||
@@ -2253,6 +2260,76 @@ from. Any alias would have to invent the numbers your battery is priced against.
|
|||||||
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
||||||
Belpex/EPEX **day-ahead**, not to imbalance.)
|
Belpex/EPEX **day-ahead**, not to imbalance.)
|
||||||
|
|
||||||
|
#### `price_service` — your own price server, and what it falls back to
|
||||||
|
|
||||||
|
*For installers: PRICE-01. The decision is in
|
||||||
|
`DECISIONS/PRICE-01-own-price-server.md`.*
|
||||||
|
|
||||||
|
`price_provider: price_service` reads the day-ahead prices for Belgium from the
|
||||||
|
owner's price server, `https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`.
|
||||||
|
The host is fixed in the add-on and only HTTPS is used. The token goes in
|
||||||
|
`price_service_token` and is sent as `Authorization: Bearer <token>`. The server
|
||||||
|
answers the energy-charts layout, so the same parser reads it. The price is the
|
||||||
|
wholesale energy price and nothing else, so the add-on adds your markup and
|
||||||
|
taxes exactly as for `generic_dayahead`.
|
||||||
|
|
||||||
|
**Where the prices come from.** Each step is used only if it has a price for
|
||||||
|
now. A server that answers with last night's prices is treated as down.
|
||||||
|
|
||||||
|
| Step | Source | When it is used | What the log says (once per change) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | The price server | It answers, with a price for now | `The price service answers again.` on the way back. |
|
||||||
|
| 2 | energy-charts.info | Step 1 fails: down, timeout, refused token (401), error (5xx), a damaged answer, or no price for now | `The price service does not answer, so prices come from energy-charts.` Or `The price service refused the token, so prices come from energy-charts.` |
|
||||||
|
| 3 | The prices saved in `/data/price_history.json` | Steps 1 and 2 fail | `No price source answers, so the add-on uses the prices it saved.` |
|
||||||
|
| none | Nothing | No saved price covers now either | `No price source answers, and no saved price covers now.` |
|
||||||
|
|
||||||
|
The saved prices are the last answers that worked, kept on disk, so a site
|
||||||
|
whose server and energy-charts are both down for a day goes on planning from
|
||||||
|
what it has. It keeps the days the history holds (today, tomorrow once it was
|
||||||
|
fetched, and a day back). They are the all-in prices as stored: a change of
|
||||||
|
markup or taxes shows from the next fetch that works. Going back up the chain
|
||||||
|
is logged once as well: `Prices come from energy-charts again.` and `The price
|
||||||
|
service answers again.` The refresh runs every 30 minutes.
|
||||||
|
|
||||||
|
**When there is no price for now (step none).** One status line, `Prices: No
|
||||||
|
prices since 00:00, maintenance waits.` (on the add-on page; the Home screen says
|
||||||
|
the same in English and Dutch), and one notification, *No prices*. Both go when a
|
||||||
|
source has a price for now again. "Since" is the moment the last price ran out.
|
||||||
|
Before the first refresh has finished there is no line, so a start-up is not a
|
||||||
|
gap. Control is not touched: the battery follows the meter as ever, battery
|
||||||
|
modes and trading read what prices there are, and only the maintenance charge
|
||||||
|
waits.
|
||||||
|
|
||||||
|
**The maintenance charge and the cheap window.** With this provider the
|
||||||
|
maintenance charge gate reads the provider's prices and no Home Assistant
|
||||||
|
entity. It keeps the rule the Home Assistant pair gave: the charge may run
|
||||||
|
when the **current 15-minute price is at or below today's average**, today
|
||||||
|
being the Brussels day. Moving a site from the pair to `price_service` does not
|
||||||
|
change when the charge runs. Where the two can differ is listed in the decision
|
||||||
|
record: the Home Assistant average was rounded to 0.00001 EUR/kWh, and so is
|
||||||
|
this one before it is compared. With no price for now the gate is shut, as it
|
||||||
|
was when an entity was unavailable. *Cheap window* on the status page shows this
|
||||||
|
answer. This is not the *cheap hours* rule of battery modes and not
|
||||||
|
`cheap_now()`.
|
||||||
|
|
||||||
|
**Moving a site from the Home Assistant price sensors to `price_service`.**
|
||||||
|
Do this on the Configuration tab, in one save, and restart once:
|
||||||
|
|
||||||
|
1. Set **Dynamic price source** (`price_provider`) to `price_service`.
|
||||||
|
2. Paste the token into **Price service token** (`price_service_token`).
|
||||||
|
3. **Clear** **Current price entity** (`price_now_entity`) and **Average price
|
||||||
|
entity** (`price_avg_entity`). The add-on refuses to start with a price
|
||||||
|
source and these two together.
|
||||||
|
4. Keep **Supplier markup** and **Distribution, levies and VAT** as they are
|
||||||
|
for a wholesale price. If the Home Assistant sensors already included them,
|
||||||
|
the figures were never in these two options, so nothing else changes.
|
||||||
|
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one. It
|
||||||
|
stays the fallback for trading when no source answers.
|
||||||
|
6. Remove the `price_service` REST and template sensors from Home Assistant
|
||||||
|
when you are satisfied. They are no longer read.
|
||||||
|
|
||||||
|
To go back: put the two entities back, set the source to `off`, and restart.
|
||||||
|
|
||||||
#### So what should a Belgian dynamic-tariff customer do today?
|
#### So what should a Belgian dynamic-tariff customer do today?
|
||||||
|
|
||||||
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
||||||
@@ -2809,6 +2886,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
|
|||||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||||
because the add-on reads exactly one and the loser is silent. Use one.
|
because the add-on reads exactly one and the loser is silent. Use one.
|
||||||
|
|
||||||
|
To move a site off them onto the owner's price server, see [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to):
|
||||||
|
it lists which options to set and which to clear.
|
||||||
|
|
||||||
### The three buckets — which price components can move a decision
|
### The three buckets — which price components can move a decision
|
||||||
|
|
||||||
*For installers: card TARIFF-17.*
|
*For installers: card TARIFF-17.*
|
||||||
@@ -3282,6 +3362,15 @@ days before it tries again, so it does not empty the battery every night. To
|
|||||||
try sooner, start a cycle on the add-on page. If it keeps happening, check that
|
try sooner, start a cycle on the add-on page. If it keeps happening, check that
|
||||||
the battery follows its commands and that the cheap hours leave time to charge.
|
the battery follows its commands and that the cheap hours leave time to charge.
|
||||||
|
|
||||||
|
### No prices
|
||||||
|
|
||||||
|
No price source has a price for the present moment: not the price server,
|
||||||
|
not energy-charts, and nothing saved. The monthly battery cycle waits, because
|
||||||
|
it will not charge from the grid without a price to judge by. Control of the
|
||||||
|
battery goes on as before. Check that the add-on can reach the internet and that
|
||||||
|
the price service token is right. The notification goes by itself when a price
|
||||||
|
is back.
|
||||||
|
|
||||||
### For installers: notifications
|
### For installers: notifications
|
||||||
|
|
||||||
This part is for installers. The texts live in `app/messages.py` and follow
|
This part is for installers. The texts live in `app/messages.py` and follow
|
||||||
@@ -3302,6 +3391,7 @@ default `info` hides it.
|
|||||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||||
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
||||||
|
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
|
||||||
|
|
||||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||||
@@ -3499,6 +3589,7 @@ for a new install*.
|
|||||||
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||||
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||||||
|
| Prices | Only while no price source has a price for now: *No prices since 00:00, maintenance waits.* See *No prices* under Notifications. |
|
||||||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||||||
|
|
||||||
### For installers: status page
|
### For installers: status page
|
||||||
|
|||||||
@@ -582,6 +582,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
|||||||
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
||||||
runs += cyc
|
runs += cyc
|
||||||
lines += waits
|
lines += waits
|
||||||
|
if getattr(c, "price_gap", False):
|
||||||
|
lines.append(line_no_prices(c.price_gap_since, now))
|
||||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||||
if c.p1_enabled:
|
if c.p1_enabled:
|
||||||
# The guard's ceiling: the cap, or this month's peak once above it.
|
# The guard's ceiling: the cap, or this month's peak once above it.
|
||||||
@@ -1268,6 +1270,13 @@ def line_cycle_waits(name) -> str:
|
|||||||
return _t("cycle_waits", name=name)
|
return _t("cycle_waits", name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def line_no_prices(since, now) -> str:
|
||||||
|
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||||
|
if since is None:
|
||||||
|
return _t("no_prices_yet")
|
||||||
|
return _t("no_prices_since", at=messages.since_text(brussels(since), brussels(now)))
|
||||||
|
|
||||||
|
|
||||||
def line_peak_limit(limit_w, shadow) -> str:
|
def line_peak_limit(limit_w, shadow) -> str:
|
||||||
return _w(shadow, "step_in", kw=_kw(limit_w))
|
return _w(shadow, "step_in", kw=_kw(limit_w))
|
||||||
|
|
||||||
@@ -1387,6 +1396,12 @@ def _samples() -> list:
|
|||||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||||
("plan", run_house_amount()),
|
("plan", run_house_amount()),
|
||||||
("plan", line_cycle_waits("Battery B")),
|
("plan", line_cycle_waits("Battery B")),
|
||||||
|
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||||
|
tzinfo=timezone.utc))),
|
||||||
|
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
|
||||||
|
tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 10, 14, 9, 0,
|
||||||
|
tzinfo=timezone.utc))),
|
||||||
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
||||||
{"eur_kwh": 0.36, "label": "19:00"})),
|
{"eur_kwh": 0.36, "label": "19:00"})),
|
||||||
("plan", line_prices(None, None, None)),
|
("plan", line_prices(None, None, None)),
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ from .p1 import P1Ingest, build_source, is_enabled
|
|||||||
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
||||||
from .price import required_spread as price_required_spread
|
from .price import required_spread as price_required_spread
|
||||||
from .price import uncertainty_of as price_uncertainty_of
|
from .price import uncertainty_of as price_uncertainty_of
|
||||||
|
from .price_chain import PriceWatch, gate_for, uses_chain
|
||||||
from .price_history import PriceHistory
|
from .price_history import PriceHistory
|
||||||
from .static_tariff import resolve_tariff, static_gated, static_status
|
from .static_tariff import resolve_tariff, static_gated, static_status
|
||||||
from .tariff import PeakStore, PeakTracker, next_quarter
|
from .tariff import PeakStore, PeakTracker, next_quarter
|
||||||
@@ -1202,6 +1203,16 @@ class Controller:
|
|||||||
self.peak_fc = None
|
self.peak_fc = None
|
||||||
self.cheap = False
|
self.cheap = False
|
||||||
self._price_gated = False # MAINT-13: is a price gate set up
|
self._price_gated = False # MAINT-13: is a price gate set up
|
||||||
|
# PRICE-01. Which step of the price chain the prices come from (logged
|
||||||
|
# once per change), the last series that was published (so a gap can say
|
||||||
|
# since when), and the gap itself: True while a refresh has finished and
|
||||||
|
# no published price covers now. The notification is raised once per
|
||||||
|
# gap and taken down when it ends.
|
||||||
|
self.price_watch = PriceWatch()
|
||||||
|
self._last_published = None
|
||||||
|
self.price_gap = False
|
||||||
|
self.price_gap_since = None
|
||||||
|
self._price_gap_announced = False
|
||||||
self._gated_key = None # MAINT-13: (options, hour) of the cache
|
self._gated_key = None # MAINT-13: (options, hour) of the cache
|
||||||
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
||||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||||
@@ -3435,6 +3446,12 @@ class Controller:
|
|||||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||||
self._price_gated = True
|
self._price_gated = True
|
||||||
self.tariff_words = "Prices from Home Assistant entities"
|
self.tariff_words = "Prices from Home Assistant entities"
|
||||||
|
elif uses_chain(o):
|
||||||
|
# PRICE-01. A provider with fallbacks gives the maintenance gate
|
||||||
|
# its own prices: the current 15-minute price against today's
|
||||||
|
# average, as the Home Assistant pair did. Still no I/O here - the
|
||||||
|
# series is the one the background refresh stored.
|
||||||
|
self._provider_gate(datetime.now(timezone.utc))
|
||||||
else:
|
else:
|
||||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||||
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
||||||
@@ -3998,6 +4015,35 @@ class Controller:
|
|||||||
inv.refusal_note())]
|
inv.refusal_note())]
|
||||||
if n]
|
if n]
|
||||||
|
|
||||||
|
def _provider_gate(self, now: datetime) -> None:
|
||||||
|
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
|
||||||
|
|
||||||
|
The gate exists whenever the provider does: no price for now is a
|
||||||
|
closed gate, so the charge waits and does not run unpriced. The gap
|
||||||
|
flag stays False until the first refresh has finished, so a start-up
|
||||||
|
is not a gap.
|
||||||
|
"""
|
||||||
|
plan = self.fresh_tariff_plan(now)
|
||||||
|
published = None if plan is None else plan.published
|
||||||
|
if published is not None:
|
||||||
|
self._last_published = published
|
||||||
|
gate = gate_for(published, now)
|
||||||
|
self.cheap, self._price_gated = gate.cheap, True
|
||||||
|
self.tariff_words = gate.words
|
||||||
|
self.price_gap = (not gate.covered) and self.tariff_at is not None
|
||||||
|
last = self._last_published
|
||||||
|
if gate.covered or last is None:
|
||||||
|
self.price_gap_since = None
|
||||||
|
elif last.covers(now):
|
||||||
|
# The last series still covers now, but no refresh has made it
|
||||||
|
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
|
||||||
|
# gap began when the plan was retired.
|
||||||
|
self.price_gap_since = (
|
||||||
|
None if self.tariff_at is None
|
||||||
|
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
|
||||||
|
else:
|
||||||
|
self.price_gap_since = last.last_end_before(now)
|
||||||
|
|
||||||
def _static_gated(self, o: dict, now: datetime) -> bool:
|
def _static_gated(self, o: dict, now: datetime) -> bool:
|
||||||
"""`static_gated()`, worked out once an hour: it builds the same
|
"""`static_gated()`, worked out once an hour: it builds the same
|
||||||
month-long plan as `static_status()` (about 20 ms with a profile), and
|
month-long plan as `static_status()` (about 20 ms with a profile), and
|
||||||
@@ -5536,6 +5582,11 @@ class Controller:
|
|||||||
# is not, and test_freeze.py part 5 fails on exactly that.
|
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||||
await self.check_meter_frozen()
|
await self.check_meter_frozen()
|
||||||
|
|
||||||
|
# --- PRICE-01: no price for now, said once and taken down once --------
|
||||||
|
# Not a safety check, so it is one of the cheap ones: an attribute test
|
||||||
|
# and, on a change only, one Home Assistant call.
|
||||||
|
await self.check_price_gap()
|
||||||
|
|
||||||
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
||||||
# Unconstrained where the two above are not: it grades
|
# Unconstrained where the two above are not: it grades
|
||||||
# `self.circ_wd.tripped`, which was advanced near the top of this method
|
# `self.circ_wd.tripped`, which was advanced near the top of this method
|
||||||
@@ -6152,6 +6203,33 @@ class Controller:
|
|||||||
await self.hass.call("persistent_notification", "create",
|
await self.hass.call("persistent_notification", "create",
|
||||||
messages.not_delivering(inv.name, slug).data())
|
messages.not_delivering(inv.name, slug).data())
|
||||||
|
|
||||||
|
async def check_price_gap(self) -> None:
|
||||||
|
"""PRICE-01. One notification when no price covers now, gone when one does.
|
||||||
|
|
||||||
|
`price_gap` is worked out on the tick from the stored plan. A gap
|
||||||
|
raises the notification once: the flag is set BEFORE the await, for
|
||||||
|
`check_circulating()`'s reason (a Home Assistant call can raise or
|
||||||
|
hang, and a retry every cycle would be a storm). When the gap ends the
|
||||||
|
same notification is taken down. Control is not touched either way: the
|
||||||
|
maintenance charge waits because its gate is shut (`_provider_gate`),
|
||||||
|
and nothing here decides that.
|
||||||
|
"""
|
||||||
|
if self.price_gap and not self._price_gap_announced:
|
||||||
|
self._price_gap_announced = True
|
||||||
|
await self.hass.call("persistent_notification", "create",
|
||||||
|
messages.no_prices(self._gap_text()).data())
|
||||||
|
elif not self.price_gap and self._price_gap_announced:
|
||||||
|
self._price_gap_announced = False
|
||||||
|
await self.hass.call("persistent_notification", "dismiss",
|
||||||
|
messages.prices_back().data())
|
||||||
|
|
||||||
|
def _gap_text(self) -> str:
|
||||||
|
"""`00:00` for a gap that began today, `13 Oct at 21:00` before that."""
|
||||||
|
if self.price_gap_since is None:
|
||||||
|
return ""
|
||||||
|
return messages.since_text(brussels(self.price_gap_since),
|
||||||
|
brussels(datetime.now(timezone.utc)))
|
||||||
|
|
||||||
async def check_meter_frozen(self) -> None:
|
async def check_meter_frozen(self) -> None:
|
||||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||||
|
|
||||||
@@ -6286,7 +6364,7 @@ class Controller:
|
|||||||
try:
|
try:
|
||||||
plan = await resolve_tariff(
|
plan = await resolve_tariff(
|
||||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||||
history=self.price_history)
|
history=self.price_history, watch=self.price_watch)
|
||||||
standing = self.fresh_tariff_plan(now)
|
standing = self.fresh_tariff_plan(now)
|
||||||
# Three terms, and each of them is the only false one in some
|
# Three terms, and each of them is the only false one in some
|
||||||
# check: the feed answered (replace), nothing current stands
|
# check: the feed answered (replace), nothing current stands
|
||||||
@@ -7363,6 +7441,9 @@ class Controller:
|
|||||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("Tariff", self.tariff_words),
|
||||||
|
# PRICE-01: only while no price covers now.
|
||||||
|
*((("Prices", messages.row_no_prices(self._gap_text())),)
|
||||||
|
if self.price_gap else ()),
|
||||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
||||||
# between a 48-hour plan and a 12-hour one on a weekday morning.
|
# between a 48-hour plan and a 12-hour one on a weekday morning.
|
||||||
# It is its own row rather than a clause of "Tariff" because it
|
# It is its own row rather than a clause of "Tariff" because it
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ S_NOT_DELIVERING = "An inverter is not delivering"
|
|||||||
T_METER_FROZEN = "Meter reading may be stuck"
|
T_METER_FROZEN = "Meter reading may be stuck"
|
||||||
T_MAINT_FAILED = "Battery cycle did not finish"
|
T_MAINT_FAILED = "Battery cycle did not finish"
|
||||||
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
||||||
|
T_NO_PRICES = "No prices"
|
||||||
|
T_NO_PRICES_NL = "Geen prijzen"
|
||||||
|
|
||||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||||
@@ -138,6 +140,7 @@ ID_CIRCULATING = "goodwe_circulating_power"
|
|||||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||||
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
||||||
|
ID_NO_PRICES = "goodwe_no_prices"
|
||||||
|
|
||||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||||
# The setting is named by its label, "Inverter rating (W)".
|
# The setting is named by its label, "Inverter rating (W)".
|
||||||
@@ -314,6 +317,51 @@ def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice
|
|||||||
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
|
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
|
||||||
|
|
||||||
|
|
||||||
|
def since_text(since, now) -> str:
|
||||||
|
"""PRICE-01: `00:00` for a moment today, `13 Oct at 21:00` for an earlier
|
||||||
|
day. Both arguments are already in the site's zone. Empty for no moment."""
|
||||||
|
if since is None:
|
||||||
|
return ""
|
||||||
|
return f"{since:%H:%M}" if since.date() == now.date() else when(since)
|
||||||
|
|
||||||
|
|
||||||
|
def no_prices(since_text: str = "") -> Notice:
|
||||||
|
"""PRICE-01: no source has a price for now. Said once, gone with the gap."""
|
||||||
|
if since_text:
|
||||||
|
en = (f"No prices since {since_text}, so the monthly battery cycle "
|
||||||
|
f"waits until they return.")
|
||||||
|
nl = (f"Geen prijzen sinds {since_text}, dus de maandelijkse "
|
||||||
|
f"batterijcyclus wacht tot ze terug zijn.")
|
||||||
|
else:
|
||||||
|
en = ("No prices have come in yet, so the monthly battery cycle waits "
|
||||||
|
"until they do.")
|
||||||
|
nl = ("Er zijn nog geen prijzen binnen, dus de maandelijkse "
|
||||||
|
"batterijcyclus wacht tot ze er zijn.")
|
||||||
|
return Notice(ID_NO_PRICES, WARNING, T_NO_PRICES, en,
|
||||||
|
body_nl=f"{T_NO_PRICES_NL}. {nl}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Dismiss:
|
||||||
|
"""The payload that takes one notification down again."""
|
||||||
|
id: str
|
||||||
|
|
||||||
|
def data(self) -> dict:
|
||||||
|
return {"notification_id": self.id}
|
||||||
|
|
||||||
|
|
||||||
|
def prices_back() -> Dismiss:
|
||||||
|
"""PRICE-01: the 'No prices' notification, taken down when a price is back."""
|
||||||
|
return Dismiss(ID_NO_PRICES)
|
||||||
|
|
||||||
|
|
||||||
|
def row_no_prices(since_text: str = "") -> str:
|
||||||
|
"""PRICE-01: the status row while no price covers now."""
|
||||||
|
if since_text:
|
||||||
|
return f"No prices since {since_text}, maintenance waits."
|
||||||
|
return "No prices yet, maintenance waits."
|
||||||
|
|
||||||
|
|
||||||
def row_maint_waits(until_text: str) -> str:
|
def row_maint_waits(until_text: str) -> str:
|
||||||
"""MAINT-13: the status row while a failed cycle waits."""
|
"""MAINT-13: the status row while a failed cycle waits."""
|
||||||
return f"Waits until {until_text} after a cycle that did not finish."
|
return f"Waits until {until_text} after a cycle that did not finish."
|
||||||
@@ -1186,6 +1234,9 @@ def status_samples() -> list:
|
|||||||
True)),
|
True)),
|
||||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||||
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
||||||
|
("row_no_prices", AMBER, row_no_prices("00:00")),
|
||||||
|
("row_no_prices", AMBER, row_no_prices("13 Oct at 21:00")),
|
||||||
|
("row_no_prices", AMBER, row_no_prices()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1218,6 +1269,9 @@ def samples() -> list:
|
|||||||
meter_frozen(90),
|
meter_frozen(90),
|
||||||
maint_failed("charge", 7),
|
maint_failed("charge", 7),
|
||||||
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
||||||
|
no_prices("00:00"),
|
||||||
|
no_prices("13 Oct at 21:00"),
|
||||||
|
no_prices(),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1250,6 +1304,7 @@ LABELS = {
|
|||||||
"price_formula": "Your own tariff formula",
|
"price_formula": "Your own tariff formula",
|
||||||
"price_export_formula": "Your own injection formula",
|
"price_export_formula": "Your own injection formula",
|
||||||
"price_csv_path": "Price CSV file",
|
"price_csv_path": "Price CSV file",
|
||||||
|
"price_service_token": "Price service token",
|
||||||
"price_now_entity": "Current price entity",
|
"price_now_entity": "Current price entity",
|
||||||
"price_avg_entity": "Average price entity",
|
"price_avg_entity": "Average price entity",
|
||||||
"arbitrage_enabled": "Trade on the price spread",
|
"arbitrage_enabled": "Trade on the price spread",
|
||||||
|
|||||||
@@ -750,6 +750,18 @@ class _Series(PriceRead):
|
|||||||
except Absence:
|
except Absence:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def last_end_before(self, instant: datetime):
|
||||||
|
"""When the last price that has run out by `instant` stopped covering,
|
||||||
|
or None if no price has (PRICE-01: "no prices since 00:00").
|
||||||
|
|
||||||
|
Each point's end is the one `_cover_table()` worked out, so a hole in
|
||||||
|
the series is reported from where the hole starts and not from the end
|
||||||
|
of the day.
|
||||||
|
"""
|
||||||
|
u = instant.astimezone(timezone.utc)
|
||||||
|
ends = [end for _start, end, _p in self._cover if end <= u]
|
||||||
|
return max(ends) if ends else None
|
||||||
|
|
||||||
def window(self, since: datetime, until: datetime) -> tuple:
|
def window(self, since: datetime, until: datetime) -> tuple:
|
||||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||||
|
|
||||||
@@ -906,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
|
|||||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||||
"price_csv_path": "/share/goodwe_prices.csv",
|
"price_csv_path": "/share/goodwe_prices.csv",
|
||||||
"price_feed_url": "",
|
"price_feed_url": "",
|
||||||
|
# PRICE-01. The bearer token of the owner's price server. A secret: it is
|
||||||
|
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
|
||||||
|
# the status page or a notification with it in.
|
||||||
|
"price_service_token": "",
|
||||||
"price_horizon_h": 48,
|
"price_horizon_h": 48,
|
||||||
"price_timeout_s": 10,
|
"price_timeout_s": 10,
|
||||||
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
||||||
@@ -992,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
|
||||||
|
# from any text the price code builds out of an error.
|
||||||
|
SECRET_OPTIONS = frozenset({"price_service_token"})
|
||||||
|
|
||||||
|
|
||||||
|
def scrub(text, opts) -> str:
|
||||||
|
"""`text` with every secret option's value replaced by `***`.
|
||||||
|
|
||||||
|
The price code builds reasons out of library errors, and a library may put
|
||||||
|
a request header into its error text. Taking the value out here is the
|
||||||
|
last line of defence; the plugin also never puts it in a message itself.
|
||||||
|
"""
|
||||||
|
out = str(text)
|
||||||
|
for key in SECRET_OPTIONS:
|
||||||
|
secret = str((opts or {}).get(key) or "").strip()
|
||||||
|
if len(secret) >= 4:
|
||||||
|
out = out.replace(secret, "***")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class PluginOptions:
|
class PluginOptions:
|
||||||
"""The options dict, minus the keys only the framework may read.
|
"""The options dict, minus the keys only the framework may read.
|
||||||
|
|
||||||
@@ -1216,6 +1252,13 @@ class PriceProvider(ABC):
|
|||||||
|
|
||||||
id = "?"
|
id = "?"
|
||||||
|
|
||||||
|
# PRICE-01. The ids to try, in order, when this source cannot answer, and
|
||||||
|
# whether the last answer is kept for a day without any source. Empty and
|
||||||
|
# False for every plugin that existed before, which is what keeps them
|
||||||
|
# exactly as they were: `price_chain.py` is not used for them at all.
|
||||||
|
FALLBACKS: tuple = ()
|
||||||
|
KEEPS_LAST_GOOD: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def capabilities(cls) -> ProviderCapabilities:
|
def capabilities(cls) -> ProviderCapabilities:
|
||||||
@@ -1533,19 +1576,54 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
|
|||||||
# Transport - one place, HTTPS only, host checked against the declaration
|
# Transport - one place, HTTPS only, host checked against the declaration
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
async def https_get(url: str, timeout_s: float) -> bytes:
|
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
|
||||||
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
||||||
|
|
||||||
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
||||||
to be importable at all. AC3 says it works fully offline; that includes a
|
to be importable at all. AC3 says it works fully offline; that includes a
|
||||||
machine where the HTTP stack is missing.
|
machine where the HTTP stack is missing.
|
||||||
|
|
||||||
|
PRICE-01. `headers` is None for every plugin but `price_service`, and with
|
||||||
|
None this is the code it has always been, byte for byte. With headers it
|
||||||
|
answers in the SAFE words of `status_words()` and never lets a library
|
||||||
|
error through: an error from a request that carried a token is the one
|
||||||
|
place a token could be printed by somebody else's code.
|
||||||
"""
|
"""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
||||||
async with aiohttp.ClientSession(timeout=t) as s:
|
if headers is None:
|
||||||
async with s.get(url) as r:
|
async with aiohttp.ClientSession(timeout=t) as s:
|
||||||
r.raise_for_status()
|
async with s.get(url) as r:
|
||||||
return await r.read()
|
r.raise_for_status()
|
||||||
|
return await r.read()
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=t) as s:
|
||||||
|
async with s.get(url, headers=headers) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
raise SourceUnavailable(status_words(r.status))
|
||||||
|
return await r.read()
|
||||||
|
except SourceUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise SourceUnavailable(
|
||||||
|
f"the price service did not answer ({type(e).__name__})")
|
||||||
|
|
||||||
|
|
||||||
|
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
|
||||||
|
# reads it to tell "the owner must act" from "the server is down", so it is a
|
||||||
|
# constant both sides import.
|
||||||
|
AUTH_REFUSED = "the price service refused the token"
|
||||||
|
|
||||||
|
|
||||||
|
def status_words(status: int) -> str:
|
||||||
|
"""What an HTTP status from the price service is called. No header text."""
|
||||||
|
if status in (401, 403):
|
||||||
|
return f"{AUTH_REFUSED} ({status})"
|
||||||
|
if status == 429:
|
||||||
|
return "the price service says too many requests (429)"
|
||||||
|
if status >= 500:
|
||||||
|
return f"the price service has an error ({status})"
|
||||||
|
return f"the price service answered {status}"
|
||||||
|
|
||||||
|
|
||||||
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
||||||
@@ -1870,6 +1948,91 @@ class GenericDayAhead(PriceProvider):
|
|||||||
provisional_default=False)
|
provisional_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class PriceService(PriceProvider):
|
||||||
|
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
|
||||||
|
|
||||||
|
SOURCES: energy
|
||||||
|
The framework ADDS: markup, taxes
|
||||||
|
|
||||||
|
The server answers the energy-charts layout (`unix_seconds`, `price`,
|
||||||
|
`unit`) plus `covers`, the complete Brussels days it holds. The price is
|
||||||
|
the wholesale energy price and nothing else, so markup and taxes are added
|
||||||
|
by the framework exactly as for `generic_dayahead`.
|
||||||
|
|
||||||
|
The host is fixed here, HTTPS only, and a bearer token (the option
|
||||||
|
`price_service_token`) goes in the `Authorization` header. The token is not
|
||||||
|
put into any message this class builds.
|
||||||
|
|
||||||
|
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
|
||||||
|
then the stored prices; this class only fetches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = "price_service"
|
||||||
|
|
||||||
|
HOST = "prices.kammenstraatha.duckdns.org"
|
||||||
|
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
|
||||||
|
FALLBACKS = ("generic_dayahead",)
|
||||||
|
KEEPS_LAST_GOOD = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def capabilities(cls):
|
||||||
|
return ProviderCapabilities(
|
||||||
|
product=PriceProduct.DAY_AHEAD,
|
||||||
|
sources=frozenset({Component.ENERGY}),
|
||||||
|
egress_hosts=(cls.HOST,))
|
||||||
|
|
||||||
|
async def fetch(self, opts, since, until, transport):
|
||||||
|
token = str(opts.get("price_service_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise SourceUnavailable("no token is set for the price service")
|
||||||
|
if any(c.isspace() or not c.isprintable() for c in token):
|
||||||
|
raise SourceUnavailable(
|
||||||
|
"the price service token has a space or a line break in it")
|
||||||
|
check_egress(self.URL, self.capabilities())
|
||||||
|
try:
|
||||||
|
body = await transport(
|
||||||
|
self.URL, float(opts.get("price_timeout_s")),
|
||||||
|
headers={"Authorization": "Bearer " + token})
|
||||||
|
except SourceUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise SourceUnavailable(
|
||||||
|
f"the price service did not answer ({type(e).__name__})")
|
||||||
|
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||||
|
scale=0.001,
|
||||||
|
provisional_default=False)
|
||||||
|
covers = _covered_days(body)
|
||||||
|
if covers is None:
|
||||||
|
return points
|
||||||
|
whole = [p for p in points
|
||||||
|
if brussels(p.start).date().isoformat() in covers]
|
||||||
|
if not whole:
|
||||||
|
raise SourceUnavailable(
|
||||||
|
"the price service sent no complete day")
|
||||||
|
return whole
|
||||||
|
|
||||||
|
|
||||||
|
def _covered_days(body):
|
||||||
|
"""The `covers` list of a price service answer as a set, or None if absent.
|
||||||
|
|
||||||
|
A day the server does not list as complete is not used: an hour missing
|
||||||
|
inside a day is a hole, and a hole is not a free hour.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||||
|
else str(body)
|
||||||
|
covers = json.loads(text).get("covers")
|
||||||
|
except (ValueError, UnicodeDecodeError, AttributeError):
|
||||||
|
return None
|
||||||
|
if covers is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(covers, list) or \
|
||||||
|
not all(isinstance(d, str) for d in covers):
|
||||||
|
raise SourceUnavailable("the price service sent a bad list of days")
|
||||||
|
return set(covers)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class ManualCsv(PriceProvider):
|
class ManualCsv(PriceProvider):
|
||||||
"""A file on disk. AC3: the documented fallback, fully offline.
|
"""A file on disk. AC3: the documented fallback, fully offline.
|
||||||
@@ -2169,9 +2332,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
|||||||
except FrameworkOption:
|
except FrameworkOption:
|
||||||
raise # a plugin bug, not a site condition
|
raise # a plugin bug, not a site condition
|
||||||
except SourceUnavailable as e:
|
except SourceUnavailable as e:
|
||||||
return PriceRead.no_data(f"{pid}: {e}")
|
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
|
||||||
except Exception as e: # noqa: BLE001 - see the docstring
|
except Exception as e: # noqa: BLE001 - see the docstring
|
||||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
return PriceRead.no_data(
|
||||||
|
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
|
||||||
|
|
||||||
_emit_startup_preview(plugin, pid, points)
|
_emit_startup_preview(plugin, pid, points)
|
||||||
return PriceRead.series(points, caps.product, pid,
|
return PriceRead.series(points, caps.product, pid,
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||||
|
|
||||||
|
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||||
|
instead of straight from `load_prices()`. The order is fixed:
|
||||||
|
|
||||||
|
1. the provider itself (the owner's price server)
|
||||||
|
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||||
|
3. the prices the history store kept from the last fetch that worked
|
||||||
|
|
||||||
|
A step counts only if its answer holds a price for NOW. A server that answers
|
||||||
|
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||||
|
it is skipped, with the same log line.
|
||||||
|
|
||||||
|
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||||
|
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||||
|
always took.
|
||||||
|
|
||||||
|
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||||
|
provider: the current 15-minute price is at or below today's average. It is the
|
||||||
|
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||||
|
from the provider's own prices, so moving a site from the pair to the provider
|
||||||
|
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||||
|
says how equal, and where it is not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
|
||||||
|
provider_id, scrub)
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe.price.chain")
|
||||||
|
|
||||||
|
# The four places a plan can come from, best first. A higher number is further
|
||||||
|
# down the chain.
|
||||||
|
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
|
||||||
|
|
||||||
|
# What a step is called in a status line.
|
||||||
|
SOURCE_WORDS = {
|
||||||
|
"price_service": "the price service",
|
||||||
|
"generic_dayahead": "energy-charts",
|
||||||
|
"price_history": "saved prices",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def uses_chain(opts: dict) -> bool:
|
||||||
|
"""Does the selected provider ask for the fallback chain?"""
|
||||||
|
cls = PROVIDERS.get(provider_id(opts))
|
||||||
|
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Resolved:
|
||||||
|
"""What the chain found. `published` is the series BEFORE the yesterday
|
||||||
|
fill and the window, which is what the day's average has to be read from:
|
||||||
|
the plan itself starts at the current hour."""
|
||||||
|
|
||||||
|
read: PriceRead
|
||||||
|
step: int
|
||||||
|
reasons: tuple = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def published(self):
|
||||||
|
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceWatch:
|
||||||
|
"""Which step the prices come from, and one log line per change of it.
|
||||||
|
|
||||||
|
The line is written when the step CHANGES and not before. Nothing repeats
|
||||||
|
while a source stays down, and nothing is written for a refresh that
|
||||||
|
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||||
|
beside it, with the token taken out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.step = None
|
||||||
|
self.auth_refused = False
|
||||||
|
|
||||||
|
def note(self, step: int, reasons=(), opts=None) -> None:
|
||||||
|
old, self.step = self.step, step
|
||||||
|
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
|
||||||
|
if old == step:
|
||||||
|
return
|
||||||
|
if reasons:
|
||||||
|
_LOG.debug("Price sources said: %s.",
|
||||||
|
scrub("; ".join(reasons), opts))
|
||||||
|
if step == STEP_OWN:
|
||||||
|
if old is None:
|
||||||
|
_LOG.info("Prices come from the price service.")
|
||||||
|
else:
|
||||||
|
_LOG.info("The price service answers again.")
|
||||||
|
elif step == STEP_PUBLIC:
|
||||||
|
if old is not None and old > step:
|
||||||
|
_LOG.info("Prices come from energy-charts again.")
|
||||||
|
elif self.auth_refused:
|
||||||
|
_LOG.warning("The price service refused the token, so prices "
|
||||||
|
"come from energy-charts.")
|
||||||
|
else:
|
||||||
|
_LOG.warning("The price service does not answer, so prices "
|
||||||
|
"come from energy-charts.")
|
||||||
|
elif step == STEP_STORED:
|
||||||
|
_LOG.warning("No price source answers, so the add-on uses the "
|
||||||
|
"prices it saved.")
|
||||||
|
else:
|
||||||
|
_LOG.warning("No price source answers, and no saved price covers "
|
||||||
|
"now.")
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||||
|
history, stamp: datetime, watch: PriceWatch,
|
||||||
|
load) -> Resolved:
|
||||||
|
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||||
|
|
||||||
|
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||||
|
stays the one module that fetches prices. Never raises
|
||||||
|
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||||
|
reason names every step, so the §11.4 row applies exactly as for a single
|
||||||
|
provider that failed.
|
||||||
|
"""
|
||||||
|
pid = provider_id(opts)
|
||||||
|
cls = PROVIDERS[pid]
|
||||||
|
reasons = []
|
||||||
|
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||||
|
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||||
|
# for the first provider and would send energy-charts' request to a
|
||||||
|
# URL it was never meant for.
|
||||||
|
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||||
|
price_feed_url="")
|
||||||
|
read = await load(o, since, until, transport)
|
||||||
|
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
|
||||||
|
# One fallback is shipped. Any further one would also be "public".
|
||||||
|
where = STEP_OWN if step == 0 else STEP_PUBLIC
|
||||||
|
watch.note(where, reasons, opts)
|
||||||
|
return Resolved(read, where, tuple(reasons))
|
||||||
|
reasons.append(read.match(
|
||||||
|
series=lambda r: f"{sid}: it has no price for now",
|
||||||
|
no_data=lambda r: r.reason))
|
||||||
|
if cls.KEEPS_LAST_GOOD:
|
||||||
|
kept = history.stored(stamp)
|
||||||
|
if kept.match(series=lambda r: r.covers(stamp),
|
||||||
|
no_data=lambda _r: False):
|
||||||
|
watch.note(STEP_STORED, reasons, opts)
|
||||||
|
return Resolved(kept, STEP_STORED, tuple(reasons))
|
||||||
|
reasons.append(kept.match(
|
||||||
|
series=lambda r: "price_history: it has no price for now",
|
||||||
|
no_data=lambda r: r.reason))
|
||||||
|
watch.note(STEP_NONE, reasons, opts)
|
||||||
|
# The reason is short on purpose: it is printed on the status page and in
|
||||||
|
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||||
|
return Resolved(PriceRead.no_data(
|
||||||
|
"no price source has a price for now"), STEP_NONE,
|
||||||
|
tuple(scrub(r, opts) for r in reasons))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The maintenance charge gate, read from the provider's prices
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Gate:
|
||||||
|
"""What the 30 s tick takes from the standing plan.
|
||||||
|
|
||||||
|
`covered` is whether a published price covers now. When it is False,
|
||||||
|
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||||
|
`since` is when the cover ended, for the status line, or None when nothing
|
||||||
|
was ever published.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cheap: bool
|
||||||
|
covered: bool
|
||||||
|
since: datetime | None
|
||||||
|
words: str
|
||||||
|
|
||||||
|
|
||||||
|
NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||||
|
|
||||||
|
|
||||||
|
def _round5(value: float) -> float:
|
||||||
|
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||||
|
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||||
|
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||||
|
return round(float(value), 5)
|
||||||
|
|
||||||
|
|
||||||
|
def gate_for(published, now: datetime) -> Gate:
|
||||||
|
"""The charge gate for `now`, from the series the chain published.
|
||||||
|
|
||||||
|
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||||
|
below the average of today's prices, today being the Brussels day. This is
|
||||||
|
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||||
|
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||||
|
hours of the whole plan, and it is not meant to be.
|
||||||
|
|
||||||
|
`published` is a series or None. With no price for now there is no gate
|
||||||
|
answer: the charge waits.
|
||||||
|
"""
|
||||||
|
if published is None:
|
||||||
|
return NO_PLAN
|
||||||
|
if not published.covers(now):
|
||||||
|
return Gate(False, False, published.last_end_before(now),
|
||||||
|
"No price for now")
|
||||||
|
today = brussels(now).date()
|
||||||
|
todays = [p.eur_per_kwh for p in published.points
|
||||||
|
if not p.provisional and brussels(p.start).date() == today]
|
||||||
|
here = published.at(now)
|
||||||
|
if here.provisional or not todays:
|
||||||
|
return Gate(False, False, published.last_end_before(now),
|
||||||
|
"No price for now")
|
||||||
|
avg = sum(todays) / len(todays)
|
||||||
|
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
|
||||||
|
source = SOURCE_WORDS.get(published.provider, published.provider)
|
||||||
|
return Gate(cheap, True, None,
|
||||||
|
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
|
||||||
|
f"{'cheap' if cheap else 'not cheap'} against today's average "
|
||||||
|
f"of {avg:.4f}")
|
||||||
@@ -581,7 +581,7 @@ class PriceHistory:
|
|||||||
|
|
||||||
# -- the one public entry point -----------------------------------------
|
# -- the one public entry point -----------------------------------------
|
||||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||||
stamp: datetime) -> PriceRead:
|
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||||
|
|
||||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||||
@@ -599,7 +599,11 @@ class PriceHistory:
|
|||||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||||
back when that is every hour.
|
back when that is every hour.
|
||||||
"""
|
"""
|
||||||
self._remember(read, stamp)
|
if remember:
|
||||||
|
# PRICE-01: False only for a read that CAME FROM this store
|
||||||
|
# (`stored()`), which is already filed; filing it again would
|
||||||
|
# rewrite the same file for nothing, and nothing else may pass it.
|
||||||
|
self._remember(read, stamp)
|
||||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||||
since=since, until=until, stamp=stamp)
|
since=since, until=until, stamp=stamp)
|
||||||
|
|
||||||
@@ -798,6 +802,37 @@ class PriceHistory:
|
|||||||
str(rec.get("provider") or "price_history"),
|
str(rec.get("provider") or "price_history"),
|
||||||
stamp)
|
stamp)
|
||||||
|
|
||||||
|
def stored(self, stamp: datetime) -> PriceRead:
|
||||||
|
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||||
|
|
||||||
|
This is the LAST step of the price server's fallback (price_chain.py):
|
||||||
|
the answer of the last fetch that worked, kept on disk, so a site can
|
||||||
|
plan for as long as the days it holds last. It is a read of what was
|
||||||
|
PUBLISHED and filed, in the order they happened, and it is never the
|
||||||
|
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||||
|
read is filled, and it is not filed again).
|
||||||
|
|
||||||
|
The prices are the all-in ones stored when they were fetched. They are
|
||||||
|
not worked out again, so a change of markup or taxes shows from the
|
||||||
|
next fetch that works, not in these.
|
||||||
|
"""
|
||||||
|
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||||
|
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||||
|
f"instant")
|
||||||
|
points = []
|
||||||
|
for day in sorted(self._days):
|
||||||
|
rec = self._days[day]
|
||||||
|
got, _bad = _parse_points(rec.get("points") if isinstance(
|
||||||
|
rec, dict) else None)
|
||||||
|
points.extend(got)
|
||||||
|
if not points:
|
||||||
|
why = self.last_error or "it holds no prices"
|
||||||
|
return PriceRead.no_data(
|
||||||
|
f"the price history at {self.path} gives nothing: {why}")
|
||||||
|
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||||
|
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||||
|
"price_history", stamp)
|
||||||
|
|
||||||
# -- observability -------------------------------------------------------
|
# -- observability -------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def words(self) -> str:
|
def words(self) -> str:
|
||||||
@@ -873,9 +908,14 @@ class NoHistory:
|
|||||||
last_error = "no history is kept by this caller"
|
last_error = "no history is kept by this caller"
|
||||||
words = "not kept"
|
words = "not kept"
|
||||||
|
|
||||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
def carry(self, read: PriceRead, *, since, until, stamp,
|
||||||
|
remember: bool = True) -> PriceRead:
|
||||||
return read
|
return read
|
||||||
|
|
||||||
|
def stored(self, stamp: datetime) -> PriceRead:
|
||||||
|
return PriceRead.no_data("this caller keeps no price history, so "
|
||||||
|
"there are no stored prices")
|
||||||
|
|
||||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||||
return PriceRead.no_data(
|
return PriceRead.no_data(
|
||||||
"this caller keeps no price history, so there are no prices from "
|
"this caller keeps no price history, so there are no prices from "
|
||||||
|
|||||||
@@ -204,12 +204,13 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from . import messages
|
from . import messages, price_chain
|
||||||
from .price import (
|
from .price import (
|
||||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||||
)
|
)
|
||||||
|
from .price_chain import uses_chain
|
||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||||
|
|
||||||
@@ -793,6 +794,13 @@ class TariffPlan:
|
|||||||
self.read = read
|
self.read = read
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
self.origin = origin
|
self.origin = origin
|
||||||
|
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
|
||||||
|
# cut it to the hours ahead: the maintenance gate needs the whole of
|
||||||
|
# today to know its average. None for every plan that did not come
|
||||||
|
# through the chain, which is every plan of every other provider.
|
||||||
|
self.published = None
|
||||||
|
self.step = None
|
||||||
|
self.reasons = ()
|
||||||
|
|
||||||
# -- derived facts -------------------------------------------------------
|
# -- derived facts -------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
@@ -1274,7 +1282,8 @@ def static_status(opts: dict, now: datetime):
|
|||||||
|
|
||||||
|
|
||||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||||
transport=None, now=None, *, history) -> TariffPlan:
|
transport=None, now=None, *, history,
|
||||||
|
watch=None) -> TariffPlan:
|
||||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||||
|
|
||||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||||
@@ -1330,13 +1339,30 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
|||||||
if not provider_configured(opts):
|
if not provider_configured(opts):
|
||||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||||
|
|
||||||
|
if uses_chain(opts):
|
||||||
|
# PRICE-01. The provider names fallbacks, so its prices come from the
|
||||||
|
# first step of the chain that has one for now. Nothing else does.
|
||||||
|
found = await price_chain.resolve(
|
||||||
|
opts, since, until, transport, history, stamp,
|
||||||
|
watch if watch is not None else price_chain.PriceWatch(),
|
||||||
|
load=load_prices)
|
||||||
|
plan = found.read.match(
|
||||||
|
series=lambda r: _provider_plan(
|
||||||
|
opts, r, since, until, stamp, history,
|
||||||
|
remember=found.step != price_chain.STEP_STORED),
|
||||||
|
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||||
|
plan.published, plan.step = found.published, found.step
|
||||||
|
plan.reasons = found.reasons
|
||||||
|
return plan
|
||||||
|
|
||||||
read = await load_prices(opts, since, until, transport)
|
read = await load_prices(opts, since, until, transport)
|
||||||
return read.match(
|
return read.match(
|
||||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||||
|
|
||||||
|
|
||||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
def _provider_plan(opts, read, since, until, stamp, history, *,
|
||||||
|
remember=True) -> TariffPlan:
|
||||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||||
|
|
||||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||||
@@ -1344,7 +1370,12 @@ def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
|||||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||||
fetch does rather than being special-cased here.
|
fetch does rather than being special-cased here.
|
||||||
"""
|
"""
|
||||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
if remember:
|
||||||
|
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||||
|
else:
|
||||||
|
# PRICE-01: a read that came out of the store is not filed again.
|
||||||
|
filled = history.carry(read, since=since, until=until, stamp=stamp,
|
||||||
|
remember=False)
|
||||||
return filled.match(
|
return filled.match(
|
||||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
|
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
|
||||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
||||||
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
||||||
},
|
},
|
||||||
@@ -410,6 +410,8 @@
|
|||||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||||
},
|
},
|
||||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||||
|
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||||
|
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||||
"step_in": "The batteries step in above {kw}.",
|
"step_in": "The batteries step in above {kw}.",
|
||||||
"step_in_would": "The batteries would step in above {kw}.",
|
"step_in_would": "The batteries would step in above {kw}.",
|
||||||
"period": "This 15-minute period is heading for {kw}.",
|
"period": "This 15-minute period is heading for {kw}.",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
|
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
|
||||||
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
|
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
|
||||||
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
|
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
|
||||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
|
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
|
||||||
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
|
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
|
||||||
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
|
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
|
||||||
},
|
},
|
||||||
@@ -345,6 +345,8 @@
|
|||||||
},
|
},
|
||||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||||
|
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||||
|
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||||
"step_in": "De batterijen springen bij boven {kw}.",
|
"step_in": "De batterijen springen bij boven {kw}.",
|
||||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||||
"period": "Dit kwartier gaat richting {kw}.",
|
"period": "Dit kwartier gaat richting {kw}.",
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ DERIVED = {
|
|||||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||||
}
|
}
|
||||||
# Option keys the page never shows and never writes: passed through as stored.
|
# Option keys the page never shows and never writes: passed through as stored.
|
||||||
HIDDEN: dict = {}
|
# PRICE-01: the price service token is a secret. It is set in the Configuration
|
||||||
|
# tab (a password field there) and this page never draws it.
|
||||||
|
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
||||||
|
|
||||||
|
|
||||||
def fields():
|
def fields():
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ from . import uimodel as U
|
|||||||
from .configui import help_of, label_of, problems_by_field, split_group
|
from .configui import help_of, label_of, problems_by_field, split_group
|
||||||
from .validate import power_request_entities
|
from .validate import power_request_entities
|
||||||
|
|
||||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||||
ONLY_IF_HELD = ()
|
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||||
|
# the Configuration tab and the page only keeps it (PRICE-01).
|
||||||
|
ONLY_IF_HELD = ("price_service",)
|
||||||
_TIERS = ("bad", "warn", "info")
|
_TIERS = ("bad", "warn", "info")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1706,6 +1706,22 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
|||||||
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||||
"choose 'off'.")
|
"choose 'off'.")
|
||||||
|
|
||||||
|
# PRICE-01. The price service answers only to its token. Without one every
|
||||||
|
# fetch is refused and the add-on lives on its fallbacks, which the
|
||||||
|
# operator would not see: a source they chose, overridden by a layer they
|
||||||
|
# cannot see. A token the server cannot read as a header is the same case.
|
||||||
|
if pid == "price_service":
|
||||||
|
token = str(opts.get("price_service_token", "") or "").strip()
|
||||||
|
if not token:
|
||||||
|
problems.append(
|
||||||
|
f"{S('price_provider')} is 'price_service', but "
|
||||||
|
f"{S('price_service_token')} is empty. Set the token, or "
|
||||||
|
"choose another source.")
|
||||||
|
elif any(c.isspace() or not c.isprintable() for c in token):
|
||||||
|
problems.append(
|
||||||
|
f"{S('price_service_token')} has a space or a line break in "
|
||||||
|
"it. Paste the token again, with nothing around it.")
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -556,6 +556,10 @@ options:
|
|||||||
# naming that consequence on every fetch that uses an override; clearing this
|
# naming that consequence on every fetch that uses an override; clearing this
|
||||||
# option is what restores the full horizon.
|
# option is what restores the full horizon.
|
||||||
price_feed_url: ""
|
price_feed_url: ""
|
||||||
|
# PRICE-01. The bearer token for the owner's price server
|
||||||
|
# (`price_provider: price_service`). A password field. Empty with any other
|
||||||
|
# source. Never printed: not in the log, the status page or a notification.
|
||||||
|
price_service_token: ""
|
||||||
price_horizon_h: 48
|
price_horizon_h: 48
|
||||||
price_timeout_s: 10
|
price_timeout_s: 10
|
||||||
|
|
||||||
@@ -1085,7 +1089,7 @@ schema:
|
|||||||
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
||||||
# typed - and register() refuses to re-register a retired id, so the two
|
# typed - and register() refuses to re-register a retired id, so the two
|
||||||
# cannot both claim one.
|
# cannot both claim one.
|
||||||
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||||
price_markup_eur_kwh: float(0,1)
|
price_markup_eur_kwh: float(0,1)
|
||||||
price_taxes_eur_kwh: float(0,1)
|
price_taxes_eur_kwh: float(0,1)
|
||||||
|
|
||||||
@@ -1119,6 +1123,7 @@ schema:
|
|||||||
price_formula: str?
|
price_formula: str?
|
||||||
price_csv_path: str?
|
price_csv_path: str?
|
||||||
price_feed_url: str?
|
price_feed_url: str?
|
||||||
|
price_service_token: password?
|
||||||
price_horizon_h: int(1,72)
|
price_horizon_h: int(1,72)
|
||||||
price_timeout_s: int(2,60)
|
price_timeout_s: int(2,60)
|
||||||
price_now_entity: str?
|
price_now_entity: str?
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from app import arbitrage as _arb
|
from app import arbitrage as _arb
|
||||||
from named_absence import first
|
from named_absence import first
|
||||||
from app import main as _main
|
from app import main as _main
|
||||||
|
from app.price_chain import PriceWatch
|
||||||
from app import peakguard as _pg
|
from app import peakguard as _pg
|
||||||
from app.arbiter import (
|
from app.arbiter import (
|
||||||
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
||||||
@@ -2311,6 +2312,7 @@ class _T12Controller:
|
|||||||
self.tariff_plan = None
|
self.tariff_plan = None
|
||||||
self.tariff_at = None
|
self.tariff_at = None
|
||||||
self.price_history = NoHistory()
|
self.price_history = NoHistory()
|
||||||
|
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
|
||||||
self.stop_event = asyncio.Event()
|
self.stop_event = asyncio.Event()
|
||||||
self.stop_event.set() # so the inter-refresh wait returns
|
self.stop_event.set() # so the inter-refresh wait returns
|
||||||
self._ticks = 0
|
self._ticks = 0
|
||||||
|
|||||||
@@ -534,11 +534,15 @@ FIXTURES = {
|
|||||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||||
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
|
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
|
||||||
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
||||||
|
# PRICE-01: the owner's price server answers the same columnar layout.
|
||||||
|
"price_service": (columnar_feed(EPEX_EUR_MWH),
|
||||||
|
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||||
}
|
}
|
||||||
|
|
||||||
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
|
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
|
||||||
f"{sorted(PROVIDERS)}",
|
f"{sorted(PROVIDERS)}",
|
||||||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv"})
|
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
|
||||||
|
"price_service"})
|
||||||
check(f"...and the three withdrawn ids resolve to NOTHING "
|
check(f"...and the three withdrawn ids resolve to NOTHING "
|
||||||
f"{sorted(RETIRED_PROVIDERS)}",
|
f"{sorted(RETIRED_PROVIDERS)}",
|
||||||
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
|
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
|
||||||
@@ -571,7 +575,7 @@ def canned(body):
|
|||||||
"""A transport that serves recorded bytes and records that it was used."""
|
"""A transport that serves recorded bytes and records that it was used."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def t(url, timeout_s):
|
async def t(url, timeout_s, headers=None):
|
||||||
calls.append((url, timeout_s))
|
calls.append((url, timeout_s))
|
||||||
return body
|
return body
|
||||||
t.calls = calls
|
t.calls = calls
|
||||||
@@ -603,6 +607,9 @@ def load_any(pid):
|
|||||||
# is exactly what section 3 grades.
|
# is exactly what section 3 grades.
|
||||||
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
|
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
|
||||||
transport=canned(body))
|
transport=canned(body))
|
||||||
|
if pid == "price_service":
|
||||||
|
return load(pid, {"price_service_token": "test-token-1234"},
|
||||||
|
transport=canned(body))
|
||||||
return load(pid, transport=canned(body))
|
return load(pid, transport=canned(body))
|
||||||
|
|
||||||
|
|
||||||
@@ -898,7 +905,16 @@ _networked = sorted(pid for pid, cls in PROVIDERS.items()
|
|||||||
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
|
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
|
||||||
# the same parser. A networked plugin with no capture is what this line exists
|
# the same parser. A networked plugin with no capture is what this line exists
|
||||||
# to forbid.
|
# to forbid.
|
||||||
_replayed = ["custom_formula", "generic_dayahead"]
|
#
|
||||||
|
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
|
||||||
|
# price server answers only to a token this session does not hold, so no real
|
||||||
|
# answer of its own exists to replay. Its answer is the energy-charts layout
|
||||||
|
# (design, API section), so test_price_service.py replays the REAL energy-charts
|
||||||
|
# two-day capture with the three extra keys the design lists (`covers`,
|
||||||
|
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
|
||||||
|
# design's. The first answer captured from the live server on the rig test
|
||||||
|
# replaces it - that is the open item, and it is in the PRICE-01 report.
|
||||||
|
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
|
||||||
check(f"the networked plugins are {_networked}, and EVERY one of them is "
|
check(f"the networked plugins are {_networked}, and EVERY one of them is "
|
||||||
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
|
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
|
||||||
f"reaches a network this file has never seen answer (TARIFF-07)",
|
f"reaches a network this file has never seen answer (TARIFF-07)",
|
||||||
@@ -1252,7 +1268,7 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
|
|||||||
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
|
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
|
||||||
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
|
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
|
||||||
# the document never carried.
|
# the document never carried.
|
||||||
_SETTLED_FEED = {"generic_dayahead", "custom_formula"}
|
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
|
||||||
for pid in sorted(set(READS) - _SETTLED_FEED):
|
for pid in sorted(set(READS) - _SETTLED_FEED):
|
||||||
read = READS[pid]
|
read = READS[pid]
|
||||||
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
||||||
|
|||||||
@@ -1133,6 +1133,11 @@ _T17_KEYS = ["price_taxes_profile"]
|
|||||||
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
|
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
|
||||||
# and it lives in this dict and in config.yaml and nowhere else.
|
# and it lives in this dict and in config.yaml and nowhere else.
|
||||||
_T16_KEYS = ["price_export_formula"]
|
_T16_KEYS = ["price_export_formula"]
|
||||||
|
# PRICE-01 added the eighth: `price_service_token`, the bearer token of the
|
||||||
|
# owner's price server. An ADDITION, like the three above: nothing was renamed,
|
||||||
|
# every other provider ignores it, and it lives in this dict and in config.yaml
|
||||||
|
# (as a password field) and nowhere else.
|
||||||
|
_P01_KEYS = ["price_service_token"]
|
||||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||||
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
|
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
|
||||||
f"none of its own {_defaults}",
|
f"none of its own {_defaults}",
|
||||||
@@ -1142,7 +1147,7 @@ check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
|||||||
"price_horizon_h", "price_timeout_s",
|
"price_horizon_h", "price_timeout_s",
|
||||||
"static_profile"]
|
"static_profile"]
|
||||||
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
|
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
|
||||||
+ _T16_KEYS))
|
+ _T16_KEYS + _P01_KEYS))
|
||||||
# ...and the later keys are genuinely NEW, not a rename of something that was
|
# ...and the later keys are genuinely NEW, not a rename of something that was
|
||||||
# already there, or the line above would pass while the mirror silently lost a
|
# already there, or the line above would pass while the mirror silently lost a
|
||||||
# key.
|
# key.
|
||||||
@@ -1151,7 +1156,7 @@ check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
|
|||||||
"are all still present",
|
"are all still present",
|
||||||
isinstance(_defaults, list)
|
isinstance(_defaults, list)
|
||||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
|
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
|
||||||
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
|
- set(_T17_KEYS) - set(_T16_KEYS) - set(_P01_KEYS)) == 9)
|
||||||
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
|
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
|
||||||
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
|
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
|
||||||
# `price_taxes_profile` would also count nine. The scalar path being unchanged
|
# `price_taxes_profile` would also count nine. The scalar path being unchanged
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -199,6 +199,9 @@ configuration:
|
|||||||
price_feed_url:
|
price_feed_url:
|
||||||
name: "Tariff · Price feed URL override"
|
name: "Tariff · Price feed URL override"
|
||||||
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
|
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
|
||||||
|
price_service_token:
|
||||||
|
name: "Tariff · Price service token"
|
||||||
|
description: "For the price service only. The token you were given for it. Leave it empty with any other price source."
|
||||||
price_horizon_h:
|
price_horizon_h:
|
||||||
name: "Tariff · Price look-ahead (hours)"
|
name: "Tariff · Price look-ahead (hours)"
|
||||||
description: "How many hours of prices to ask the source for."
|
description: "How many hours of prices to ask the source for."
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ configuration:
|
|||||||
price_feed_url:
|
price_feed_url:
|
||||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||||
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
|
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
|
||||||
|
price_service_token:
|
||||||
|
name: "Tarief · Token van de prijsdienst"
|
||||||
|
description: "Alleen voor de prijsdienst. Het token dat u ervoor kreeg. Laat leeg bij een andere prijsbron."
|
||||||
price_horizon_h:
|
price_horizon_h:
|
||||||
name: "Tarief · Prijzen vooruit (uren)"
|
name: "Tarief · Prijzen vooruit (uren)"
|
||||||
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
|
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
|
||||||
|
|||||||
Reference in New Issue
Block a user