Skip to content

Commit ff3587d

Browse files
mgazzaclaude
andauthored
feat(spotprice): German supplier prices — Ostrom, Octopus Energy Germany, aWATTar, EWS (#5431)
* feat(spotprice): Ostrom provider Read Ostrom's (DE) dynamic tariff from its public API with an OAuth2 client-credentials token (cached until shortly before expiry, renewed once on a 401). The contract is looked up once per run: the single active dynamic contract, or spotprice_ostrom_contract_id. Prices are requested at the contract's postcode so they carry the local taxes, levies and variable grid fees, and the documented per-kWh price (grossKwhPrice + grossKwhTaxAndLevies, VAT included) is used as-is, like Tibber. A reply with no levies at all is rejected. Suppliers now share a generic "supplier" source with its own fetch time, back-off and cache. With spotprice_provider unset, the one supplier whose credentials are set is used; credentials for several suppliers, or half an Ostrom pair, are a single configuration error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(spotprice): Octopus Energy Germany provider Read the active electricity agreement of an Octopus Energy Germany account from its Kraken GraphQL API (api.oeg-kraken.energy). The API key is exchanged for a JWT through obtainKrakenToken(APIKey), cached until shortly before its exp claim, sent without a Bearer prefix and replaced once when Kraken stops accepting it. The account number is looked up from the key unless spotprice_octopus_de_account is set. Rates come from the agreement under properties -> electricityMalos: a dynamic tariff's unitRateForecast slots as published, otherwise the time-of-use timeslots repeated daily in local time, or a fixed unit rate over the window, clipped to the agreement's validity. These are gross unit rates and are used as-is, like Tibber. Every query was checked against the live schema without credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(spotprice): aWATTar (tado Energy HOURLY) provider Add provider awattar: hourly spot prices for DE-LU and AT from the public aWATTar market data API (api.awattar.de / api.awattar.at, no key), the prices tado Energy's dynamic HOURLY tariff (formerly aWATTar's) is billed on. It falls back to ENTSO-E (with a token) and then Energy-Charts; other zones go straight to the fallbacks. Add spotprice_markup_percent, a percentage of the absolute spot price added before VAT, for any spot provider. It defaults to the published 3% for awattar and to 0 otherwise. The fixed 1.5 ct/kWh and the grid fees stay in spotprice_markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(spotprice): EWS Schönau provider Add provider ews for EWS Schönau's "Ökostrom Dynamisch": quarter-hourly gross prices for today and tomorrow from EWS's dynamic price API (api.ews-schoenau.de/v1/dynamicprices/EWS-OEKO-DYN), authenticated with an X-API-Key header. EWS issues keys on request. The totals are gross cents per kWh with grid fees and levies included and are used as-is, like Tibber. The response shape (today/tomorrow lists of startsAt/total) is only known from third-party use, so a reply whose typical value looks like euros rather than cents is rejected instead of pricing every interval a hundred times too low. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spotprice): fail loudly on ambiguous supplier replies Octopus Energy Germany: - Expand a forecast entry holding several time-of-use rates by its timeslot rules instead of taking the first rate. - Price a dynamic agreement only from its forecast. With no forecast yet, record an error and publish nothing rather than a flat rate. - Require spotprice_octopus_de_malo when active agreements sit on several market locations. Hand over to the next agreement on the location when the current one ends inside the window, and skip revoked agreements. - Expand timeslot rules in Europe/Berlin, not the local timezone. - Clip forecast slots to the agreement's validity. - Retry on KT-CT-1125 (token keys rotated) rather than 1139, per the Kraken error code reference. Ostrom: price ACTIVE contracts only; with none, report an error. Cache: the name carries a digest of the configured Ostrom contract or Octopus account and market location. aWATTar: average quarter-hourly fallback prices to hourly, as the HOURLY tariff bills. Docs: an "Unverified assumptions" note under each supplier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spotprice): single-successor handover, import-only averaging, explicit flat rates Octopus Energy Germany: - Chain agreements one handover at a time: the single non-revoked agreement starting at, or first after, the current end. Two claiming the same start are an error, and overlapping rates are refused rather than published. - Without a forecast, price only an explicit fixed rate (Simple, not a time-of-use product) or a time-of-use rate whose every price has timeslots. Dynamic detection also reads the product's names and contract type, as the schema has no market-linked flag. - Map the repeated autumn hour to its first occurrence and skipped spring times to the jump, so rule boundaries agree. - KT-CT-1139 on a query is an auth error and is not retried. Ostrom: ACTIVE matches in any case. When no contract has a status field at all, the single dynamic contract is used with one warning. aWATTar: average fallback quarter hours to hourly for import only; export keeps the native quarter hours. Cache: cached supplier prices carry the detected contract or account and market location, and are dropped when detection finds another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spotprice): key the supplier cache on a digest of the credential Swapping a Tibber token, Ostrom client ID, or Octopus Energy Germany or EWS API key, with no contract or market location set, kept the same cache name. The old account's prices were then restored and counted as fresh for up to six hours. The cache name now carries a salted SHA-256 of the credential and selectors, truncated to 12 hex characters. The credential itself is never written to the name or the stored data. The Kraken JWT stays bare, as the evcc project's Octopus Energy Germany tariff sends it; a test now pins that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spotprice): re-check the Ostrom contract daily and after a postcode error The Ostrom contract and postcode were looked up once and kept until a restart, so after a move the old postcode stayed priced. Without spotprice_ostrom_contract_id, the contract is now looked up again once a day, and straight after a 400/404/422 on /spot-prices, retrying once with the new postcode. A changed contract or postcode drops the prices held for the old one. With no active contract left, the usual "no active Ostrom contract" error is raised and nothing is kept for the old address. A configured contract is not re-checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spotprice): address German supplier review findings --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 308c3cd commit ff3587d

7 files changed

Lines changed: 2495 additions & 66 deletions

File tree

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ autoflake
4343
automations
4444
autopep
4545
autoupdate
46+
awattar
4647
axvline
4748
axvspan
4849
Backfeed
@@ -171,12 +172,14 @@ dstamp
171172
dstart
172173
dtype
173174
dwindow
175+
Dynamisch
174176
eauth
175177
echargingpile
176178
ecosmart
177179
Eddi
178180
eddis
179181
efgh
182+
Elektrizitätswerke
180183
elif
181184
elinter
182185
eload
@@ -374,7 +377,11 @@ lstrip
374377
luxpower
375378
malloc
376379
mallopt
380+
malo
381+
malos
377382
markdownlint
383+
marketdata
384+
marketprice
378385
matplotlib
379386
maximise
380387
maxrss
@@ -473,6 +480,7 @@ openapi
473480
openrouter
474481
openweathermap
475482
Opticaster
483+
ostrom
476484
overclaim
477485
overfitting
478486
oversizing
@@ -578,6 +586,8 @@ saverestore
578586
savez
579587
scalarstring
580588
scalings
589+
schoenau
590+
Schönau
581591
searr
582592
securetoken
583593
Segoe
@@ -655,6 +665,7 @@ syscmd
655665
sysdn
656666
syssn
657667
tabindex
668+
tado
658669
tasmota
659670
TCWORLD
660671
tdata
@@ -679,6 +690,7 @@ timekey
679690
timelapse
680691
timenow
681692
timeobj
693+
timespec
682694
timestep
683695
timestr
684696
timezone
@@ -767,3 +779,4 @@ zappi
767779
zappis
768780
zcat
769781
zigbee
782+
Ökostrom

‎apps/predbat/components.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -583,14 +583,23 @@ def load_component_class(component_info):
583583
"class": "spotprice.SpotPriceAPI",
584584
"name": "Day-ahead Spot Price Tariff",
585585
"args": {
586-
# No registry default: when unset, initialize() picks tibber whenever a Tibber token is set
587-
# (with or without a zone), else energycharts
586+
# No registry default: when unset, initialize() picks the one supplier whose credentials are
587+
# set (with or without a zone), else energycharts; credentials for several is a config error
588588
"provider": {"required": False, "config": "spotprice_provider"},
589589
"zone": {"required": False, "config": "spotprice_zone"},
590590
"entsoe_token": {"required": False, "secret": True, "config": "spotprice_entsoe_token"},
591591
"tibber_token": {"required": False, "secret": True, "config": "spotprice_tibber_token"},
592592
"tibber_home_id": {"required": False, "secret": True, "config": "spotprice_tibber_home_id"},
593+
"ostrom_client_id": {"required": False, "secret": True, "config": "spotprice_ostrom_client_id"},
594+
"ostrom_client_secret": {"required": False, "secret": True, "config": "spotprice_ostrom_client_secret"},
595+
"ostrom_contract_id": {"required": False, "secret": True, "config": "spotprice_ostrom_contract_id"},
596+
"octopus_de_api_key": {"required": False, "secret": True, "config": "spotprice_octopus_de_api_key"},
597+
"octopus_de_account": {"required": False, "secret": True, "config": "spotprice_octopus_de_account"},
598+
"octopus_de_malo": {"required": False, "secret": True, "config": "spotprice_octopus_de_malo"},
599+
"ews_api_key": {"required": False, "secret": True, "config": "spotprice_ews_api_key"},
593600
"markup": {"required": False, "config": "spotprice_markup", "default": 0.0},
601+
# No registry default: when unset, initialize() uses the provider's published percentage (3 for awattar), else 0
602+
"markup_percent": {"required": False, "config": "spotprice_markup_percent"},
594603
"vat": {"required": False, "config": "spotprice_vat", "default": 0.0},
595604
"charge_zones": {"required": False, "config": "spotprice_charge_zones", "default": []},
596605
"exchange_rate": {"required": False, "config": "spotprice_exchange_rate", "default": 1.0},
@@ -601,9 +610,10 @@ def load_component_class(component_info):
601610
"automatic": {"required": False, "config": "spotprice_automatic", "default": True},
602611
},
603612
# The provider defaults to energycharts, so gate on what a real configuration must carry: a
604-
# bidding zone for the spot sources, or a Tibber token. Without this the component would
605-
# start on every install.
606-
"required_or": ["zone", "tibber_token"],
613+
# bidding zone for the spot sources, or a supplier's credentials (either half of a pair is
614+
# enough to start it, so a missing other half is reported rather than silently ignored).
615+
# Without this the component would start on every install.
616+
"required_or": ["zone", "tibber_token", "ostrom_client_id", "ostrom_client_secret", "octopus_de_api_key", "ews_api_key"],
607617
"can_restart": True,
608618
"phase": 1,
609619
},

‎apps/predbat/config.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2871,12 +2871,20 @@
28712871
"iboost_energy_today": {"type": "sensor", "sensor_type": "float"},
28722872
"metric_octopus_gas": {"type": "sensor", "sensor_type": "float"},
28732873
"rates_gas": {"type": "dict_list"},
2874-
"spotprice_provider": {"type": "string", "empty": False, "allowed": ["entsoe", "energycharts", "tibber"]},
2874+
"spotprice_provider": {"type": "string", "empty": False, "allowed": ["entsoe", "energycharts", "tibber", "ostrom", "octopus_de", "awattar", "ews"]},
28752875
"spotprice_zone": {"type": "string", "empty": False},
28762876
"spotprice_entsoe_token": {"type": "string", "empty": False},
28772877
"spotprice_tibber_token": {"type": "string", "empty": False},
28782878
"spotprice_tibber_home_id": {"type": "string", "empty": False},
2879+
"spotprice_ostrom_client_id": {"type": "string", "empty": False},
2880+
"spotprice_ostrom_client_secret": {"type": "string", "empty": False},
2881+
"spotprice_ostrom_contract_id": {"type": "string|integer", "empty": False},
2882+
"spotprice_octopus_de_api_key": {"type": "string", "empty": False},
2883+
"spotprice_octopus_de_account": {"type": "string", "empty": False},
2884+
"spotprice_octopus_de_malo": {"type": "string|integer", "empty": False},
2885+
"spotprice_ews_api_key": {"type": "string", "empty": False},
28792886
"spotprice_markup": {"type": "float"},
2887+
"spotprice_markup_percent": {"type": "float"},
28802888
"spotprice_vat": {"type": "float"},
28812889
"spotprice_charge_zones": {"type": "dict_list"},
28822890
"spotprice_exchange_rate": {"type": "float"},

0 commit comments

Comments
 (0)