mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 04:36:37 +00:00
Add SMARD quarter-hour price provider
This commit is contained in:
@@ -7,6 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Add direct `ElecPriceSMARD` and `FeedInTariffSMARD` providers for German/Luxembourg
|
||||
day-ahead prices without relying on a supplier account or the rate-limited Energy-Charts API.
|
||||
The providers retrieve SMARD's native quarter-hour prices, cache weekly chart-data chunks, and
|
||||
extend missing horizon slots with the existing seasonal ETS forecast. Feed-in prices remain raw,
|
||||
while import prices can include retail charges.
|
||||
- Add named net electricity-price components and recurring time-window network fees. Constant
|
||||
taxes, levies, supplier charges, and the matching dynamic grid fee are added to the underlying
|
||||
market price before VAT; seasonal forecasting continues to model only the market-price component.
|
||||
- Add `FeedInTariffAkkudoktor`, using raw hourly Akkudoktor/aWATTar day-ahead market prices as
|
||||
feed-in tariff data without import charges or VAT. Quarter-hour optimization holds each hourly
|
||||
value constant for four slots.
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| charge_components_kwh | `EOS_ELECPRICE__CHARGE_COMPONENTS_KWH` | `dict[str, Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=0)])]]` | `rw` | `required` | Named constant net charge components [€/kWh]. Their sum is added to charges_kwh, variable network fees, and the market price. |
|
||||
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges [€/kWh]. Will be added to variable market price. |
|
||||
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
|
||||
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Import provider settings. |
|
||||
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
|
||||
| network_fees_kwh | `EOS_ELECPRICE__NETWORK_FEES_KWH` | `ValueTimeWindowSequence` | `rw` | `required` | Recurring time windows for variable network fees [€/kWh, net]. The first matching window is added to charges_kwh and the market price. |
|
||||
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
|
||||
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
|
||||
| smard | `EOS_ELECPRICE__SMARD` | `ElecPriceSMARDCommonSettings` | `rw` | `required` | Direct SMARD electricity price provider settings. |
|
||||
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
|
||||
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
|
||||
:::
|
||||
@@ -44,6 +47,54 @@
|
||||
"tibber": {
|
||||
"access_token": null,
|
||||
"home_id": null
|
||||
},
|
||||
"charge_components_kwh": {
|
||||
"electricity_tax": 0.0205,
|
||||
"concession_fee": 0.0132,
|
||||
"kwkg_levy": 0.00446,
|
||||
"section_19_levy": 0.01559,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"supplier_markup": 0.0
|
||||
},
|
||||
"smard": {
|
||||
"filter_id": 4169,
|
||||
"region": "DE"
|
||||
},
|
||||
"network_fees_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "7 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0095
|
||||
},
|
||||
{
|
||||
"start_time": "07:00:00.000000",
|
||||
"duration": "8 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
},
|
||||
{
|
||||
"start_time": "15:00:00.000000",
|
||||
"duration": "5 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.1565
|
||||
},
|
||||
{
|
||||
"start_time": "20:00:00.000000",
|
||||
"duration": "4 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,9 +128,58 @@
|
||||
"access_token": null,
|
||||
"home_id": null
|
||||
},
|
||||
"charge_components_kwh": {
|
||||
"electricity_tax": 0.0205,
|
||||
"concession_fee": 0.0132,
|
||||
"kwkg_levy": 0.00446,
|
||||
"section_19_levy": 0.01559,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"supplier_markup": 0.0
|
||||
},
|
||||
"smard": {
|
||||
"filter_id": 4169,
|
||||
"region": "DE"
|
||||
},
|
||||
"network_fees_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "7 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0095
|
||||
},
|
||||
{
|
||||
"start_time": "07:00:00.000000",
|
||||
"duration": "8 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
},
|
||||
{
|
||||
"start_time": "15:00:00.000000",
|
||||
"duration": "5 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.1565
|
||||
},
|
||||
{
|
||||
"start_time": "20:00:00.000000",
|
||||
"duration": "4 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
}
|
||||
]
|
||||
},
|
||||
"providers": [
|
||||
"ElecPriceAkkudoktor",
|
||||
"ElecPriceEnergyCharts",
|
||||
"ElecPriceSMARD",
|
||||
"ElecPriceTibber",
|
||||
"ElecPriceFixed",
|
||||
"ElecPriceImport"
|
||||
@@ -120,6 +220,37 @@
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for the direct SMARD electricity-price provider
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} elecprice::smard
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| filter_id | `int` | `rw` | `4169` | SMARD filter id for the German/Luxembourg day-ahead price. |
|
||||
| region | `str` | `rw` | `DE` | SMARD market region used in the chart-data endpoint. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"elecprice": {
|
||||
"smard": {
|
||||
"filter_id": 4169,
|
||||
"region": "DE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for Energy Charts electricity price provider
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
|
||||
@@ -114,6 +114,54 @@
|
||||
"tibber": {
|
||||
"access_token": null,
|
||||
"home_id": null
|
||||
},
|
||||
"charge_components_kwh": {
|
||||
"electricity_tax": 0.0205,
|
||||
"concession_fee": 0.0132,
|
||||
"kwkg_levy": 0.00446,
|
||||
"section_19_levy": 0.01559,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"supplier_markup": 0.0
|
||||
},
|
||||
"smard": {
|
||||
"filter_id": 4169,
|
||||
"region": "DE"
|
||||
},
|
||||
"network_fees_kwh": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "7 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0095
|
||||
},
|
||||
{
|
||||
"start_time": "07:00:00.000000",
|
||||
"duration": "8 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
},
|
||||
{
|
||||
"start_time": "15:00:00.000000",
|
||||
"duration": "5 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.1565
|
||||
},
|
||||
{
|
||||
"start_time": "20:00:00.000000",
|
||||
"duration": "4 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.0953
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ems": {
|
||||
@@ -123,11 +171,14 @@
|
||||
},
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"provider": "FeedInTariffAkkudoktor",
|
||||
"provider_settings": {
|
||||
"FeedInTariffAkkudoktor": null,
|
||||
"FeedInTariffFixed": null,
|
||||
"FeedInTariffEnergyCharts": null,
|
||||
"FeedInTariffImport": null
|
||||
"FeedInTariffImport": null,
|
||||
"FeedInTariffTibber": null,
|
||||
"FeedInTariffSMARD": null
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -23,11 +23,14 @@
|
||||
{
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"provider": "FeedInTariffAkkudoktor",
|
||||
"provider_settings": {
|
||||
"FeedInTariffAkkudoktor": null,
|
||||
"FeedInTariffFixed": null,
|
||||
"FeedInTariffEnergyCharts": null,
|
||||
"FeedInTariffImport": null
|
||||
"FeedInTariffImport": null,
|
||||
"FeedInTariffTibber": null,
|
||||
"FeedInTariffSMARD": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,22 +46,87 @@
|
||||
{
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
"provider": "FeedInTariffAkkudoktor",
|
||||
"provider_settings": {
|
||||
"FeedInTariffAkkudoktor": null,
|
||||
"FeedInTariffFixed": null,
|
||||
"FeedInTariffEnergyCharts": null,
|
||||
"FeedInTariffImport": null
|
||||
"FeedInTariffImport": null,
|
||||
"FeedInTariffTibber": null,
|
||||
"FeedInTariffSMARD": null
|
||||
},
|
||||
"providers": [
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffAkkudoktor",
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffImport"
|
||||
"FeedInTariffImport",
|
||||
"FeedInTariffSMARD",
|
||||
"FeedInTariffTibber"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Settings for SMARD feed-in prices shared with ``elecprice.smard``
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} feedintariff::provider_settings::FeedInTariffSMARD
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"feedintariff": {
|
||||
"provider_settings": {
|
||||
"FeedInTariffSMARD": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Settings for the Tibber feed-in tariff provider
|
||||
|
||||
Authentication is shared with ``elecprice.tibber`` so the access token and
|
||||
home id do not have to be configured twice.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} feedintariff::provider_settings::FeedInTariffTibber
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"feedintariff": {
|
||||
"provider_settings": {
|
||||
"FeedInTariffTibber": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common settings for feed in tariff data import from file or JSON string
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -154,18 +222,19 @@
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Feed In Tariff Prediction Provider Configuration
|
||||
### Settings for the Akkudoktor feed-in tariff provider
|
||||
|
||||
The public Akkudoktor price endpoint only needs the timezone already
|
||||
configured in ``general.timezone``, so no provider-specific values are
|
||||
currently required.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} feedintariff::provider_settings
|
||||
:::{table} feedintariff::provider_settings::FeedInTariffAkkudoktor
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| FeedInTariffEnergyCharts | `Optional[akkudoktoreos.prediction.feedintariffenergycharts.FeedInTariffEnergyChartsCommonSettings]` | `rw` | `None` | FeedInTariffEnergyCharts settings |
|
||||
| FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
|
||||
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -178,9 +247,46 @@
|
||||
{
|
||||
"feedintariff": {
|
||||
"provider_settings": {
|
||||
"FeedInTariffAkkudoktor": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Feed In Tariff Prediction Provider Configuration
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} feedintariff::provider_settings
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| FeedInTariffAkkudoktor | `Optional[akkudoktoreos.prediction.feedintariffakkudoktor.FeedInTariffAkkudoktorCommonSettings]` | `rw` | `None` | FeedInTariffAkkudoktor settings |
|
||||
| FeedInTariffEnergyCharts | `Optional[akkudoktoreos.prediction.feedintariffenergycharts.FeedInTariffEnergyChartsCommonSettings]` | `rw` | `None` | FeedInTariffEnergyCharts settings |
|
||||
| FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
|
||||
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
|
||||
| FeedInTariffSMARD | `Optional[akkudoktoreos.prediction.feedintariffsmard.FeedInTariffSMARDCommonSettings]` | `rw` | `None` | FeedInTariffSMARD settings |
|
||||
| FeedInTariffTibber | `Optional[akkudoktoreos.prediction.feedintarifftibber.FeedInTariffTibberCommonSettings]` | `rw` | `None` | FeedInTariffTibber settings |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"feedintariff": {
|
||||
"provider_settings": {
|
||||
"FeedInTariffAkkudoktor": null,
|
||||
"FeedInTariffFixed": null,
|
||||
"FeedInTariffEnergyCharts": null,
|
||||
"FeedInTariffImport": null
|
||||
"FeedInTariffImport": null,
|
||||
"FeedInTariffTibber": null,
|
||||
"FeedInTariffSMARD": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.3.0.dev2607071966322885`
|
||||
**Version**: `v0.3.0.dev2608010951962828`
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
||||
@@ -122,7 +122,9 @@ Note:
|
||||
|
||||
- `start_hour` (query, optional): Defaults to current hour of the day.
|
||||
|
||||
- `ngen` (query, optional): Number of indivuals to generate for genetic algorithm.
|
||||
- `ngen` (query, optional): Deprecated alias for the number of genetic generations. Defaults to optimization.genetic.generations.
|
||||
|
||||
- `individuals` (query, optional): Override optimization.genetic.individuals for this run.
|
||||
|
||||
**Request Body**:
|
||||
|
||||
|
||||
@@ -136,10 +136,13 @@ Configuration options:
|
||||
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
|
||||
- `ElecPriceSMARD`: Retrieves German/Luxembourg day-ahead prices directly from SMARD.de.
|
||||
- `ElecPriceFixed`: Caluclates from configured time window prices.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string or by endpoint data provision.
|
||||
|
||||
- `charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `charge_components_kwh`: Named constant net-charge components (€/kWh), summed automatically.
|
||||
- `network_fees_kwh.windows`: Recurring time windows with variable net-grid fees (€/kWh).
|
||||
- `vat_rate`: VAT rate factor applied to electricity price when charges are used (default: 1.19).
|
||||
- `elecpricefixed.time_windows.windows`: The time windows with associated electricity prices.
|
||||
- `elecpriceimport.import_file_path`: Path to the file to import electricity price forecast data from.
|
||||
@@ -166,12 +169,32 @@ forecasting by combining real-time market data with historical price trends.
|
||||
|
||||
Charges and VAT
|
||||
|
||||
- If `charges_kwh` configuration option is greater than 0, the electricity price is calculated as:
|
||||
`(market price + charges_kwh) * vat_rate` where `vat_rate` is configurable (default: 1.19 for 19% VAT).
|
||||
- If `charges_kwh` is set to 0, the electricity price is simply: `market_price` (no VAT applied).
|
||||
- If constant charges or a matching `network_fees_kwh` window is greater than 0, the electricity
|
||||
price is calculated as: `(market price + charges_kwh + sum(charge components) + network fee)
|
||||
* vat_rate` where
|
||||
`vat_rate` is configurable (default: 1.19 for 19% VAT).
|
||||
- If all constant charges and network-fee windows are empty or zero, the electricity price is
|
||||
simply `market_price` (no VAT applied, preserving the existing raw-market-price behaviour).
|
||||
|
||||
`charges_kwh`, every `charge_components_kwh` value, and `network_fees_kwh` are net values. Time
|
||||
windows repeat daily unless their optional `date` or `day_of_week` constraints are set. The first
|
||||
matching window is used. Named components make statutory levies and the supplier markup auditable
|
||||
instead of hiding them in one aggregate. The supplier-specific markup defaults to zero in the
|
||||
example and must be taken from the electricity supply contract.
|
||||
|
||||
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
|
||||
|
||||
### ElecPriceSMARD Provider
|
||||
|
||||
The `ElecPriceSMARD` provider retrieves quarter-hourly German/Luxembourg day-ahead prices directly from
|
||||
the public SMARD chart-data endpoint. It requests the required weekly chunks only and caches the
|
||||
combined response for one hour. Missing quarter-hour slots beyond the published day-ahead horizon are generated
|
||||
with the same daily or weekly seasonal ETS forecast as the Energy-Charts provider. Constant charges,
|
||||
time-variable network fees, and VAT are applied after forecasting the underlying market price.
|
||||
|
||||
The same raw SMARD series is also available as `FeedInTariffSMARD` for direct-marketing feed-in
|
||||
revenue. Import-only charges, network fees, and VAT are deliberately not applied there.
|
||||
|
||||
### ElecPriceFixed Provider
|
||||
|
||||
The `ElecPriceFixed` provider calculates the day-ahead electricity market prices from the configuration
|
||||
|
||||
+230
-6
@@ -8,7 +8,7 @@
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"version": "v0.3.0.dev2607101210858200"
|
||||
"version": "v0.3.0.dev2608010951962828"
|
||||
},
|
||||
"paths": {
|
||||
"/v1/admin/cache/clear": {
|
||||
@@ -2005,16 +2005,36 @@
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Number of indivuals to generate for genetic algorithm.",
|
||||
"description": "Deprecated alias for the number of genetic generations. Defaults to optimization.genetic.generations.",
|
||||
"title": "Ngen"
|
||||
},
|
||||
"description": "Number of indivuals to generate for genetic algorithm."
|
||||
"description": "Deprecated alias for the number of genetic generations. Defaults to optimization.genetic.generations."
|
||||
},
|
||||
{
|
||||
"name": "individuals",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 10
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Override optimization.genetic.individuals for this run.",
|
||||
"title": "Individuals"
|
||||
},
|
||||
"description": "Override optimization.genetic.individuals for this run."
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -3405,6 +3425,59 @@
|
||||
"tibber": {
|
||||
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
|
||||
"description": "Tibber electricity price provider settings."
|
||||
},
|
||||
"charge_components_kwh": {
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Charge Components Kwh",
|
||||
"description": "Named constant net charge components [\u20ac/kWh]. Their sum is added to charges_kwh, variable network fees, and the market price.",
|
||||
"examples": [
|
||||
{
|
||||
"concession_fee": 0.0132,
|
||||
"electricity_tax": 0.0205,
|
||||
"kwkg_levy": 0.00446,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"section_19_levy": 0.01559,
|
||||
"supplier_markup": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"smard": {
|
||||
"$ref": "#/components/schemas/ElecPriceSMARDCommonSettings",
|
||||
"description": "Direct SMARD electricity price provider settings."
|
||||
},
|
||||
"network_fees_kwh": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
|
||||
"description": "Recurring time windows for variable network fees [\u20ac/kWh, net]. The first matching window is added to charges_kwh and the market price.",
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{
|
||||
"duration": "7 hours",
|
||||
"start_time": "00:00",
|
||||
"value": 0.0095
|
||||
},
|
||||
{
|
||||
"duration": "8 hours",
|
||||
"start_time": "07:00",
|
||||
"value": 0.0953
|
||||
},
|
||||
{
|
||||
"duration": "5 hours",
|
||||
"start_time": "15:00",
|
||||
"value": 0.1565
|
||||
},
|
||||
{
|
||||
"duration": "4 hours",
|
||||
"start_time": "20:00",
|
||||
"value": 0.0953
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3477,6 +3550,59 @@
|
||||
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
|
||||
"description": "Tibber electricity price provider settings."
|
||||
},
|
||||
"charge_components_kwh": {
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Charge Components Kwh",
|
||||
"description": "Named constant net charge components [\u20ac/kWh]. Their sum is added to charges_kwh, variable network fees, and the market price.",
|
||||
"examples": [
|
||||
{
|
||||
"concession_fee": 0.0132,
|
||||
"electricity_tax": 0.0205,
|
||||
"kwkg_levy": 0.00446,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"section_19_levy": 0.01559,
|
||||
"supplier_markup": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"smard": {
|
||||
"$ref": "#/components/schemas/ElecPriceSMARDCommonSettings",
|
||||
"description": "Direct SMARD electricity price provider settings."
|
||||
},
|
||||
"network_fees_kwh": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
|
||||
"description": "Recurring time windows for variable network fees [\u20ac/kWh, net]. The first matching window is added to charges_kwh and the market price.",
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{
|
||||
"duration": "7 hours",
|
||||
"start_time": "00:00",
|
||||
"value": 0.0095
|
||||
},
|
||||
{
|
||||
"duration": "8 hours",
|
||||
"start_time": "07:00",
|
||||
"value": 0.0953
|
||||
},
|
||||
{
|
||||
"duration": "5 hours",
|
||||
"start_time": "15:00",
|
||||
"value": 0.1565
|
||||
},
|
||||
{
|
||||
"duration": "4 hours",
|
||||
"start_time": "20:00",
|
||||
"value": 0.0953
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -3605,6 +3731,33 @@
|
||||
"title": "ElecPriceImportCommonSettings",
|
||||
"description": "Common settings for elecprice data import from file or JSON String."
|
||||
},
|
||||
"ElecPriceSMARDCommonSettings": {
|
||||
"properties": {
|
||||
"filter_id": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0.0,
|
||||
"title": "Filter Id",
|
||||
"description": "SMARD filter id for the German/Luxembourg day-ahead price.",
|
||||
"default": 4169,
|
||||
"examples": [
|
||||
4169
|
||||
]
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"title": "Region",
|
||||
"description": "SMARD market region used in the chart-data endpoint.",
|
||||
"default": "DE",
|
||||
"examples": [
|
||||
"DE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ElecPriceSMARDCommonSettings",
|
||||
"description": "Common settings for the direct SMARD electricity-price provider."
|
||||
},
|
||||
"ElecPriceTibberCommonSettings": {
|
||||
"properties": {
|
||||
"access_token": {
|
||||
@@ -4278,8 +4431,28 @@
|
||||
"title": "FRBCTimerStatus",
|
||||
"description": "Current status of an FRBC Timer.\n\nIndicates when the Timer will be finished."
|
||||
},
|
||||
"FeedInTariffAkkudoktorCommonSettings": {
|
||||
"properties": {},
|
||||
"type": "object",
|
||||
"title": "FeedInTariffAkkudoktorCommonSettings",
|
||||
"description": "Settings for the Akkudoktor feed-in tariff provider.\n\nThe public Akkudoktor price endpoint only needs the timezone already\nconfigured in ``general.timezone``, so no provider-specific values are\ncurrently required."
|
||||
},
|
||||
"FeedInTariffCommonProviderSettings": {
|
||||
"properties": {
|
||||
"FeedInTariffAkkudoktor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FeedInTariffAkkudoktorCommonSettings"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "FeedInTariffAkkudoktor settings",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"FeedInTariffFixed": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4321,6 +4494,34 @@
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"FeedInTariffTibber": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FeedInTariffTibberCommonSettings"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "FeedInTariffTibber settings",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"FeedInTariffSMARD": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FeedInTariffSMARDCommonSettings"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "FeedInTariffSMARD settings",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -4351,9 +4552,12 @@
|
||||
"title": "Provider",
|
||||
"description": "Feed in tariff provider id of provider to be used.",
|
||||
"examples": [
|
||||
"FeedInTariffAkkudoktor",
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffImport"
|
||||
"FeedInTariffImport",
|
||||
"FeedInTariffSMARD",
|
||||
"FeedInTariffTibber"
|
||||
]
|
||||
},
|
||||
"provider_settings": {
|
||||
@@ -4392,9 +4596,12 @@
|
||||
"title": "Provider",
|
||||
"description": "Feed in tariff provider id of provider to be used.",
|
||||
"examples": [
|
||||
"FeedInTariffAkkudoktor",
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffImport"
|
||||
"FeedInTariffImport",
|
||||
"FeedInTariffSMARD",
|
||||
"FeedInTariffTibber"
|
||||
]
|
||||
},
|
||||
"provider_settings": {
|
||||
@@ -4501,6 +4708,18 @@
|
||||
"title": "FeedInTariffImportCommonSettings",
|
||||
"description": "Common settings for feed in tariff data import from file or JSON string."
|
||||
},
|
||||
"FeedInTariffSMARDCommonSettings": {
|
||||
"properties": {},
|
||||
"type": "object",
|
||||
"title": "FeedInTariffSMARDCommonSettings",
|
||||
"description": "Settings for SMARD feed-in prices shared with ``elecprice.smard``."
|
||||
},
|
||||
"FeedInTariffTibberCommonSettings": {
|
||||
"properties": {},
|
||||
"type": "object",
|
||||
"title": "FeedInTariffTibberCommonSettings",
|
||||
"description": "Settings for the Tibber feed-in tariff provider.\n\nAuthentication is shared with ``elecprice.tibber`` so the access token and\nhome id do not have to be configured twice."
|
||||
},
|
||||
"ForecastResponse": {
|
||||
"properties": {
|
||||
"temperature": {
|
||||
@@ -5313,6 +5532,11 @@
|
||||
"hours": {
|
||||
"type": "integer",
|
||||
"title": "Hours"
|
||||
},
|
||||
"force_update": {
|
||||
"type": "boolean",
|
||||
"title": "Force Update",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel, ValueTimeWindowSequence
|
||||
from akkudoktoreos.core.coreabc import get_prediction
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
@@ -10,6 +10,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
)
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARDCommonSettings
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettings
|
||||
|
||||
|
||||
@@ -24,6 +25,7 @@ def elecprice_provider_ids() -> list[str]:
|
||||
"ElecPriceEnergyCharts",
|
||||
"ElecPriceFixed",
|
||||
"ElecPriceImport",
|
||||
"ElecPriceSMARD",
|
||||
"ElecPriceTibber",
|
||||
]
|
||||
|
||||
@@ -83,6 +85,51 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
json_schema_extra={"description": "Tibber electricity price provider settings."},
|
||||
)
|
||||
|
||||
charge_components_kwh: dict[str, Annotated[float, Field(ge=0)]] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Named constant net charge components [€/kWh]. Their sum is added to "
|
||||
"charges_kwh, variable network fees, and the market price."
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"electricity_tax": 0.0205,
|
||||
"concession_fee": 0.0132,
|
||||
"kwkg_levy": 0.00446,
|
||||
"section_19_levy": 0.01559,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
"supplier_markup": 0.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
smard: ElecPriceSMARDCommonSettings = Field(
|
||||
default_factory=ElecPriceSMARDCommonSettings,
|
||||
json_schema_extra={"description": "Direct SMARD electricity price provider settings."},
|
||||
)
|
||||
|
||||
network_fees_kwh: ValueTimeWindowSequence = Field(
|
||||
default_factory=ValueTimeWindowSequence,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Recurring time windows for variable network fees [€/kWh, net]. "
|
||||
"The first matching window is added to charges_kwh and the market price."
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{"start_time": "00:00", "duration": "7 hours", "value": 0.0095},
|
||||
{"start_time": "07:00", "duration": "8 hours", "value": 0.0953},
|
||||
{"start_time": "15:00", "duration": "5 hours", "value": 0.1565},
|
||||
{"start_time": "20:00", "duration": "4 hours", "value": 0.0953},
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def providers(self) -> list[str]:
|
||||
|
||||
@@ -147,9 +147,6 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
|
||||
# Initialize
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
@@ -164,18 +161,40 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
|
||||
if charges_wh > 0:
|
||||
vat_rate = self.config.elecprice.vat_rate or 1.19
|
||||
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * vat_rate
|
||||
else:
|
||||
price_wh = price_eur_per_mwh / 1_000_000
|
||||
# Convert EUR/MWh to EUR/Wh and add the configured retail price components.
|
||||
price_wh = self._price_with_charges(
|
||||
price_eur_per_mwh / 1_000_000, orig_datetime
|
||||
)
|
||||
|
||||
# Store in series
|
||||
series_data.at[orig_datetime] = price_wh
|
||||
|
||||
return series_data
|
||||
|
||||
def _charges_kwh_for_datetime(self, date_time: datetime) -> float:
|
||||
"""Return constant charges plus the first matching variable network fee."""
|
||||
constant_charges_kwh = self.config.elecprice.charges_kwh or 0.0
|
||||
component_charges_kwh = sum(self.config.elecprice.charge_components_kwh.values())
|
||||
network_fees = self.config.elecprice.network_fees_kwh
|
||||
network_fee_kwh = network_fees.get_value_for_datetime(to_datetime(date_time))
|
||||
return constant_charges_kwh + component_charges_kwh + network_fee_kwh
|
||||
|
||||
def _price_with_charges(self, market_price_wh: float, date_time: datetime) -> float:
|
||||
"""Build the gross retail price from a net market price in EUR/Wh."""
|
||||
charges_kwh = self._charges_kwh_for_datetime(date_time)
|
||||
if charges_kwh <= 0:
|
||||
return market_price_wh
|
||||
vat_rate = self.config.elecprice.vat_rate or 1.19
|
||||
return (market_price_wh + charges_kwh / 1000.0) * vat_rate
|
||||
|
||||
def _price_without_charges(self, retail_price_wh: float, date_time: datetime) -> float:
|
||||
"""Reverse configured charges so ETS forecasts only the underlying market price."""
|
||||
charges_kwh = self._charges_kwh_for_datetime(date_time)
|
||||
if charges_kwh <= 0:
|
||||
return retail_price_wh
|
||||
vat_rate = self.config.elecprice.vat_rate or 1.19
|
||||
return retail_price_wh / vat_rate - charges_kwh / 1000.0
|
||||
|
||||
@staticmethod
|
||||
def _resolution_seconds(series: pd.Series) -> int:
|
||||
"""Infer the current native market interval from recent timestamps."""
|
||||
@@ -243,7 +262,9 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
|
||||
if needs_update:
|
||||
logger.info(
|
||||
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
"Update {} is needed, last in history: {}",
|
||||
self.provider_id(),
|
||||
self.highest_orig_datetime,
|
||||
)
|
||||
# Set start_date try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
@@ -260,7 +281,9 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
else:
|
||||
logger.info(
|
||||
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
|
||||
"No update {} is needed, last in history: {}",
|
||||
self.provider_id(),
|
||||
self.highest_orig_datetime,
|
||||
)
|
||||
|
||||
if not self.highest_orig_datetime: # mypy fix
|
||||
@@ -274,12 +297,25 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
)
|
||||
resolution_seconds = self._resolution_seconds(raw_series)
|
||||
slots_per_hour = 3600 // resolution_seconds
|
||||
history = self.key_to_array(
|
||||
priced_history = self.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
end_datetime=self.highest_orig_datetime,
|
||||
end_datetime=to_datetime(self.highest_orig_datetime),
|
||||
interval=to_duration(f"{resolution_seconds} seconds"),
|
||||
fill_method="linear",
|
||||
)
|
||||
history_start = self.highest_orig_datetime - to_duration(
|
||||
f"{max(len(priced_history) - 1, 0) * resolution_seconds} seconds"
|
||||
)
|
||||
history = np.array(
|
||||
[
|
||||
self._price_without_charges(
|
||||
float(price_wh),
|
||||
history_start + to_duration(f"{i * resolution_seconds} seconds"),
|
||||
)
|
||||
for i, price_wh in enumerate(priced_history)
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
covered_slots = 0
|
||||
@@ -326,11 +362,15 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
raise ValueError("No data available")
|
||||
|
||||
# write predictions into the records, update if exist.
|
||||
prediction_index = [
|
||||
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
|
||||
for i in range(len(prediction))
|
||||
]
|
||||
prediction_series = pd.Series(
|
||||
data=prediction,
|
||||
index=[
|
||||
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
|
||||
for i in range(len(prediction))
|
||||
data=[
|
||||
self._price_with_charges(float(price_wh), timestamp)
|
||||
for timestamp, price_wh in zip(prediction_index, prediction)
|
||||
],
|
||||
index=prediction_index,
|
||||
)
|
||||
self.key_from_series("elecprice_marketprice_wh", prediction_series)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Retrieve German day-ahead electricity prices directly from SMARD."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
ElecPriceEnergyCharts,
|
||||
EnergyChartsElecPrice,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
SMARD_BASE_URL = "https://www.smard.de/app/chart_data"
|
||||
|
||||
|
||||
class SmardIndex(PydanticBaseModel):
|
||||
"""Available SMARD data-chunk timestamps."""
|
||||
|
||||
timestamps: List[int]
|
||||
|
||||
|
||||
class SmardChunkMetadata(PydanticBaseModel):
|
||||
"""Metadata included in a SMARD data chunk."""
|
||||
|
||||
version: int
|
||||
created: int
|
||||
|
||||
|
||||
class SmardChunk(PydanticBaseModel):
|
||||
"""SMARD data chunk with millisecond timestamps and EUR/MWh values."""
|
||||
|
||||
meta_data: SmardChunkMetadata
|
||||
series: List[tuple[int, Optional[float]]]
|
||||
|
||||
|
||||
class ElecPriceSMARDCommonSettings(SettingsBaseModel):
|
||||
"""Common settings for the direct SMARD electricity-price provider."""
|
||||
|
||||
filter_id: int = Field(
|
||||
default=4169,
|
||||
gt=0,
|
||||
json_schema_extra={
|
||||
"description": "SMARD filter id for the German/Luxembourg day-ahead price.",
|
||||
"examples": [4169],
|
||||
},
|
||||
)
|
||||
region: str = Field(
|
||||
default="DE",
|
||||
min_length=2,
|
||||
json_schema_extra={
|
||||
"description": "SMARD market region used in the chart-data endpoint.",
|
||||
"examples": ["DE"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ElecPriceSMARD(ElecPriceEnergyCharts):
|
||||
"""Fetch SMARD day-ahead prices and extend them with the seasonal EOS forecast.
|
||||
|
||||
The provider uses the public SMARD chart-data endpoint directly. It reuses the
|
||||
Energy-Charts parsing and ETS pipeline after normalizing the response because both
|
||||
sources expose the same EUR/MWh day-ahead market-price concept.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the direct SMARD provider."""
|
||||
return "ElecPriceSMARD"
|
||||
|
||||
@classmethod
|
||||
def _validate_index(cls, json_data: bytes) -> SmardIndex:
|
||||
"""Validate a SMARD chunk index response."""
|
||||
try:
|
||||
return SmardIndex.model_validate_json(json_data)
|
||||
except ValidationError as exc:
|
||||
logger.error("SMARD index schema change: {}", exc)
|
||||
raise ValueError(f"SMARD index schema change: {exc}") from exc
|
||||
|
||||
@classmethod
|
||||
def _validate_chunk(cls, json_data: bytes) -> SmardChunk:
|
||||
"""Validate a SMARD price chunk response."""
|
||||
try:
|
||||
return SmardChunk.model_validate_json(json_data)
|
||||
except ValidationError as exc:
|
||||
logger.error("SMARD price schema change: {}", exc)
|
||||
raise ValueError(f"SMARD price schema change: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _get(url: str) -> bytes:
|
||||
"""Request a SMARD JSON resource with bounded retries."""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={"User-Agent": "Akkudoktor-EOS/SMARD price provider"},
|
||||
timeout=(5, 30),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
|
||||
last_exc = exc
|
||||
logger.warning("SMARD request attempt {}/3 failed for {}: {}", attempt, url, exc)
|
||||
if attempt < 3:
|
||||
time.sleep(2 * attempt)
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise RuntimeError(f"SMARD request failed without an exception: {url}")
|
||||
|
||||
def _chunk_timestamps(
|
||||
self, index: SmardIndex, start_datetime: datetime, end_datetime: datetime
|
||||
) -> list[int]:
|
||||
"""Select all weekly chunks overlapping the requested datetime range."""
|
||||
start_ms = int(to_datetime(start_datetime).timestamp() * 1000)
|
||||
end_ms = int(to_datetime(end_datetime).timestamp() * 1000)
|
||||
timestamps = sorted(set(index.timestamps))
|
||||
selected: list[int] = []
|
||||
for position, chunk_start in enumerate(timestamps):
|
||||
next_start = timestamps[position + 1] if position + 1 < len(timestamps) else None
|
||||
overlaps_start = next_start is None or next_start > start_ms
|
||||
if chunk_start <= end_ms and overlaps_start:
|
||||
selected.append(chunk_start)
|
||||
return selected
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
|
||||
"""Fetch and normalize quarter-hourly German/Luxembourg day-ahead prices from SMARD."""
|
||||
if not self.ems_start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
|
||||
if start_date is None:
|
||||
start_datetime = self.ems_start_datetime - to_duration("35 days")
|
||||
else:
|
||||
start_datetime = to_datetime(
|
||||
start_date, in_timezone=self.config.general.timezone
|
||||
).start_of("day")
|
||||
end_datetime = to_datetime(self.end_datetime).end_of("day")
|
||||
|
||||
settings = self.config.elecprice.smard
|
||||
filter_id = settings.filter_id
|
||||
region = settings.region
|
||||
resolution = "quarterhour"
|
||||
index_url = f"{SMARD_BASE_URL}/{filter_id}/{region}/index_{resolution}.json"
|
||||
index = self._validate_index(self._get(index_url))
|
||||
chunk_timestamps = self._chunk_timestamps(index, start_datetime, end_datetime)
|
||||
if not chunk_timestamps:
|
||||
raise ValueError("SMARD index contains no price chunks for the requested period")
|
||||
|
||||
values_by_timestamp: dict[int, float] = {}
|
||||
latest_created = 0
|
||||
for chunk_timestamp in chunk_timestamps:
|
||||
chunk_url = (
|
||||
f"{SMARD_BASE_URL}/{filter_id}/{region}/"
|
||||
f"{filter_id}_{region}_{resolution}_{chunk_timestamp}.json"
|
||||
)
|
||||
chunk = self._validate_chunk(self._get(chunk_url))
|
||||
latest_created = max(latest_created, chunk.meta_data.created)
|
||||
for timestamp_ms, price_eur_mwh in chunk.series:
|
||||
if price_eur_mwh is None:
|
||||
continue
|
||||
if int(start_datetime.timestamp() * 1000) <= timestamp_ms <= int(
|
||||
end_datetime.timestamp() * 1000
|
||||
):
|
||||
values_by_timestamp[timestamp_ms] = price_eur_mwh
|
||||
|
||||
if not values_by_timestamp:
|
||||
raise ValueError("SMARD response contains no usable day-ahead prices")
|
||||
|
||||
ordered_values = sorted(values_by_timestamp.items())
|
||||
self.update_datetime = to_datetime(
|
||||
latest_created / 1000, in_timezone=self.config.general.timezone
|
||||
)
|
||||
return EnergyChartsElecPrice(
|
||||
license_info="CC BY 4.0 Bundesnetzagentur | SMARD.de",
|
||||
unix_seconds=[timestamp_ms // 1000 for timestamp_ms, _ in ordered_values],
|
||||
price=[price for _, price in ordered_values],
|
||||
unit="EUR/MWh",
|
||||
deprecated=False,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from akkudoktoreos.prediction.feedintariffenergycharts import (
|
||||
)
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
|
||||
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARDCommonSettings
|
||||
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibberCommonSettings
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ def elecprice_provider_ids() -> list[str]:
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffImport",
|
||||
"FeedInTariffSMARD",
|
||||
"FeedInTariffTibber",
|
||||
]
|
||||
|
||||
@@ -61,6 +63,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
|
||||
default=None,
|
||||
json_schema_extra={"description": "FeedInTariffTibber settings", "examples": [None]},
|
||||
)
|
||||
FeedInTariffSMARD: Optional[FeedInTariffSMARDCommonSettings] = Field(
|
||||
default=None,
|
||||
json_schema_extra={"description": "FeedInTariffSMARD settings", "examples": [None]},
|
||||
)
|
||||
|
||||
|
||||
class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||
@@ -83,6 +89,7 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||
"FeedInTariffFixed",
|
||||
"FeedInTariffEnergyCharts",
|
||||
"FeedInTariffImport",
|
||||
"FeedInTariffSMARD",
|
||||
"FeedInTariffTibber",
|
||||
],
|
||||
},
|
||||
@@ -99,6 +106,7 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||
"FeedInTariffFixed": None,
|
||||
"FeedInTariffEnergyCharts": None,
|
||||
"FeedInTariffImport": None,
|
||||
"FeedInTariffSMARD": None,
|
||||
"FeedInTariffTibber": None,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -116,8 +116,9 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
||||
energycharts = ElecPriceEnergyCharts()
|
||||
if len(history) > 800 * slots_per_hour:
|
||||
logger.info(
|
||||
"Using weekly seasonal ETS forecast for Energy-Charts feed-in tariff "
|
||||
"Using weekly seasonal ETS forecast for {} "
|
||||
"with {} historical values.",
|
||||
self.provider_id(),
|
||||
len(history),
|
||||
)
|
||||
return energycharts._predict_ets(
|
||||
@@ -125,8 +126,9 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
||||
)
|
||||
if len(history) > 168 * slots_per_hour:
|
||||
logger.info(
|
||||
"Using daily seasonal ETS forecast for Energy-Charts feed-in tariff "
|
||||
"Using daily seasonal ETS forecast for {} "
|
||||
"with {} historical values.",
|
||||
self.provider_id(),
|
||||
len(history),
|
||||
)
|
||||
return energycharts._predict_ets(
|
||||
@@ -134,12 +136,13 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
||||
)
|
||||
if len(history) > 0:
|
||||
logger.warning(
|
||||
"Using constant median fallback for Energy-Charts feed-in tariff "
|
||||
"Using constant median fallback for {} "
|
||||
"with only {} historical values.",
|
||||
self.provider_id(),
|
||||
len(history),
|
||||
)
|
||||
return energycharts._predict_median(history, hours=slots)
|
||||
logger.error("No feed-in tariff data available for Energy-Charts prediction")
|
||||
logger.error("No feed-in tariff data available for {} prediction", self.provider_id())
|
||||
raise ValueError("No data available")
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
@@ -181,8 +184,9 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
||||
|
||||
if needs_update:
|
||||
logger.info(
|
||||
"Update FeedInTariffEnergyCharts is needed, last in history: {}, "
|
||||
"Update {} is needed, last in history: {}, "
|
||||
"force_update={}, history_refresh={}",
|
||||
self.provider_id(),
|
||||
self.highest_orig_datetime,
|
||||
bool(force_update),
|
||||
needs_history_refresh,
|
||||
@@ -211,15 +215,17 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
||||
# slots, so downstream (e.g. /gesamtlast, optimization) still
|
||||
# gets a usable feed-in tariff series.
|
||||
logger.warning(
|
||||
"Energy-Charts feed-in tariff update failed ({}); keeping "
|
||||
"{} update failed ({}); keeping "
|
||||
"existing history until {} and extrapolating the remaining "
|
||||
"slots via ETS.",
|
||||
self.provider_id(),
|
||||
exc,
|
||||
self.highest_orig_datetime,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"No update FeedInTariffEnergyCharts is needed, last in history: {}",
|
||||
"No update {} is needed, last in history: {}",
|
||||
self.provider_id(),
|
||||
self.highest_orig_datetime,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Provide direct-marketing feed-in prices from SMARD day-ahead data."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
|
||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||
|
||||
|
||||
class FeedInTariffSMARDCommonSettings(SettingsBaseModel):
|
||||
"""Settings for SMARD feed-in prices shared with ``elecprice.smard``."""
|
||||
|
||||
|
||||
class FeedInTariffSMARD(FeedInTariffEnergyCharts):
|
||||
"""Use raw SMARD day-ahead market prices for direct-marketing feed-in revenue."""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the direct SMARD feed-in provider."""
|
||||
return "FeedInTariffSMARD"
|
||||
|
||||
def _request_forecast(
|
||||
self, start_date: Optional[str] = None, force_update: Optional[bool] = False
|
||||
) -> EnergyChartsElecPrice:
|
||||
"""Reuse the cached direct SMARD request without import-price components."""
|
||||
return ElecPriceSMARD()._request_forecast( # type: ignore[call-arg]
|
||||
start_date=start_date, force_update=force_update
|
||||
)
|
||||
@@ -35,11 +35,13 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
|
||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
|
||||
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
|
||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||
LoadAkkudoktor,
|
||||
@@ -81,6 +83,7 @@ class PredictionCommonSettings(SettingsBaseModel):
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_smard = ElecPriceSMARD()
|
||||
elecprice_tibber = ElecPriceTibber()
|
||||
elecprice_fixed = ElecPriceFixed()
|
||||
elecprice_import = ElecPriceImport()
|
||||
@@ -88,6 +91,7 @@ feedintariff_energy_charts = FeedInTariffEnergyCharts()
|
||||
feedintariff_akkudoktor = FeedInTariffAkkudoktor()
|
||||
feedintariff_fixed = FeedInTariffFixed()
|
||||
feedintariff_import = FeedInTariffImport()
|
||||
feedintariff_smard = FeedInTariffSMARD()
|
||||
feedintariff_tibber = FeedInTariffTibber()
|
||||
loadforecast_akkudoktor = LoadAkkudoktor()
|
||||
loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted()
|
||||
@@ -110,6 +114,7 @@ def prediction_providers() -> (
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceSMARD,
|
||||
ElecPriceTibber,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
@@ -117,6 +122,7 @@ def prediction_providers() -> (
|
||||
FeedInTariffAkkudoktor,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
FeedInTariffSMARD,
|
||||
FeedInTariffTibber,
|
||||
LoadAkkudoktor,
|
||||
LoadAkkudoktorAdjusted,
|
||||
@@ -142,6 +148,7 @@ def prediction_providers() -> (
|
||||
global \
|
||||
elecprice_akkudoktor, \
|
||||
elecprice_energy_charts, \
|
||||
elecprice_smard, \
|
||||
elecprice_tibber, \
|
||||
elecprice_fixed, \
|
||||
elecprice_import, \
|
||||
@@ -149,6 +156,7 @@ def prediction_providers() -> (
|
||||
feedintariff_akkudoktor, \
|
||||
feedintariff_fixed, \
|
||||
feedintariff_import, \
|
||||
feedintariff_smard, \
|
||||
feedintariff_tibber, \
|
||||
loadforecast_akkudoktor, \
|
||||
loadforecast_akkudoktor_adjusted, \
|
||||
@@ -169,6 +177,7 @@ def prediction_providers() -> (
|
||||
return [
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_smard,
|
||||
elecprice_tibber,
|
||||
elecprice_fixed,
|
||||
elecprice_import,
|
||||
@@ -176,6 +185,7 @@ def prediction_providers() -> (
|
||||
feedintariff_akkudoktor,
|
||||
feedintariff_fixed,
|
||||
feedintariff_import,
|
||||
feedintariff_smard,
|
||||
feedintariff_tibber,
|
||||
loadforecast_akkudoktor,
|
||||
loadforecast_akkudoktor_adjusted,
|
||||
@@ -201,6 +211,7 @@ class Prediction(PredictionContainer):
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceSMARD,
|
||||
ElecPriceTibber,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
@@ -208,6 +219,7 @@ class Prediction(PredictionContainer):
|
||||
FeedInTariffAkkudoktor,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
FeedInTariffSMARD,
|
||||
FeedInTariffTibber,
|
||||
LoadAkkudoktor,
|
||||
LoadAkkudoktorAdjusted,
|
||||
|
||||
@@ -8,6 +8,7 @@ import requests
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import CacheFileStore
|
||||
from akkudoktoreos.config.configabc import ValueTimeWindowSequence
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
||||
AkkudoktorElecPrice,
|
||||
@@ -158,6 +159,61 @@ def test_update_data_keeps_quarter_hour_resolution(provider):
|
||||
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
|
||||
|
||||
|
||||
def test_parse_data_adds_constant_charges_variable_network_fees_and_vat(provider):
|
||||
"""Build the gross retail price from market price and the matching Module 3 fee."""
|
||||
provider.config.elecprice.charges_kwh = None
|
||||
provider.config.elecprice.charge_components_kwh = {
|
||||
"electricity_tax": 0.0205,
|
||||
"concession_fee": 0.0132,
|
||||
"kwkg_levy": 0.00446,
|
||||
"section_19_levy": 0.01559,
|
||||
"offshore_grid_levy": 0.00941,
|
||||
}
|
||||
provider.config.elecprice.vat_rate = 1.19
|
||||
provider.config.elecprice.network_fees_kwh = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
{"start_time": "00:00", "duration": "7 hours", "value": 0.0095},
|
||||
{"start_time": "07:00", "duration": "8 hours", "value": 0.0953},
|
||||
{"start_time": "15:00", "duration": "5 hours", "value": 0.1565},
|
||||
{"start_time": "20:00", "duration": "4 hours", "value": 0.0953},
|
||||
]
|
||||
)
|
||||
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
|
||||
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
|
||||
data = EnergyChartsElecPrice(
|
||||
license_info="",
|
||||
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
|
||||
price=[100.0] * len(timestamps),
|
||||
unit="EUR/MWh",
|
||||
deprecated=False,
|
||||
)
|
||||
|
||||
result_kwh = provider._parse_data(data) * 1000
|
||||
|
||||
assert result_kwh.iloc[0] == pytest.approx((0.1 + 0.06316 + 0.0095) * 1.19)
|
||||
assert result_kwh.iloc[1] == pytest.approx((0.1 + 0.06316 + 0.0953) * 1.19)
|
||||
assert result_kwh.iloc[2] == pytest.approx((0.1 + 0.06316 + 0.1565) * 1.19)
|
||||
assert result_kwh.iloc[3] == pytest.approx((0.1 + 0.06316 + 0.0953) * 1.19)
|
||||
|
||||
|
||||
def test_market_price_charge_round_trip(provider):
|
||||
"""Seasonal forecasting can remove and reapply timestamp-dependent retail charges."""
|
||||
provider.config.elecprice.charges_kwh = None
|
||||
provider.config.elecprice.charge_components_kwh = {"statutory_charges": 0.06316}
|
||||
provider.config.elecprice.vat_rate = 1.19
|
||||
provider.config.elecprice.network_fees_kwh = ValueTimeWindowSequence(
|
||||
windows=[{"start_time": "15:00", "duration": "5 hours", "value": 0.1565}]
|
||||
)
|
||||
timestamp = to_datetime("2026-01-15 16:30:00", in_timezone="Europe/Berlin")
|
||||
market_price_wh = -0.00002
|
||||
|
||||
retail_price_wh = provider._price_with_charges(market_price_wh, timestamp)
|
||||
|
||||
assert provider._price_without_charges(retail_price_wh, timestamp) == pytest.approx(
|
||||
market_price_wh
|
||||
)
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_update_data_with_incomplete_forecast(mock_get, provider):
|
||||
"""Test `_update_data` with incomplete or missing forecast data."""
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# ruff: noqa: S101
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(config_eos):
|
||||
"""Configure and return the direct SMARD singleton provider."""
|
||||
config_eos.elecprice = ElecPriceCommonSettings(provider="ElecPriceSMARD")
|
||||
provider = ElecPriceSMARD()
|
||||
provider.highest_orig_datetime = None
|
||||
get_ems().set_start_datetime(
|
||||
to_datetime("2026-07-27 00:00:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _response(payload):
|
||||
response = Mock()
|
||||
response.content = json.dumps(payload).encode()
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
@patch("akkudoktoreos.prediction.elecpricesmard.requests.get")
|
||||
def test_request_forecast_fetches_index_and_overlapping_chunks(mock_get, provider):
|
||||
"""SMARD index and weekly chunks are combined, sorted, and stripped of null values."""
|
||||
chunk_start = 1785103200000
|
||||
mock_get.side_effect = [
|
||||
_response({"timestamps": [chunk_start]}),
|
||||
_response(
|
||||
{
|
||||
"meta_data": {"version": 1, "created": 1785500527370},
|
||||
"series": [
|
||||
[1785103200000, 86.04],
|
||||
[1785106800000, None],
|
||||
[1785110400000, -1.25],
|
||||
],
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
result = provider._request_forecast(
|
||||
start_date="2026-07-27", force_update=True
|
||||
)
|
||||
|
||||
assert result.unix_seconds == [1785103200, 1785110400]
|
||||
assert result.price == [86.04, -1.25]
|
||||
assert result.license_info == "CC BY 4.0 Bundesnetzagentur | SMARD.de"
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_get.call_args_list[0].args[0].endswith("/4169/DE/index_quarterhour.json")
|
||||
assert mock_get.call_args_list[1].args[0].endswith(
|
||||
"/4169/DE/4169_DE_quarterhour_1785103200000.json"
|
||||
)
|
||||
|
||||
|
||||
def test_chunk_selection_includes_preceding_overlapping_chunk(provider):
|
||||
"""A range beginning mid-week includes the chunk that started before it."""
|
||||
index = provider._validate_index(
|
||||
json.dumps({"timestamps": [1000, 2000, 3000]}).encode()
|
||||
)
|
||||
start = to_datetime(2.5, in_timezone="UTC")
|
||||
end = to_datetime(3.5, in_timezone="UTC")
|
||||
|
||||
assert provider._chunk_timestamps(index, start, end) == [2000, 3000]
|
||||
|
||||
|
||||
def test_smard_provider_is_enabled(provider):
|
||||
assert provider.enabled()
|
||||
@@ -0,0 +1,41 @@
|
||||
# ruff: noqa: S101
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
|
||||
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def test_feed_in_tariff_smard_reuses_raw_smard_market_prices(config_eos):
|
||||
"""The feed-in provider delegates to SMARD and stores no import-price components."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"elecprice": {"provider": "ElecPriceSMARD"},
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": True,
|
||||
"provider": "FeedInTariffSMARD",
|
||||
},
|
||||
}
|
||||
)
|
||||
get_ems().set_start_datetime(
|
||||
to_datetime("2026-08-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
)
|
||||
provider = FeedInTariffSMARD()
|
||||
data = EnergyChartsElecPrice(
|
||||
license_info="CC BY 4.0 Bundesnetzagentur | SMARD.de",
|
||||
unix_seconds=[1785535200],
|
||||
price=[169.44],
|
||||
unit="EUR/MWh",
|
||||
deprecated=False,
|
||||
)
|
||||
|
||||
with patch.object(ElecPriceSMARD, "_request_forecast", return_value=data) as request:
|
||||
result = provider._request_forecast(start_date="2026-08-01", force_update=True)
|
||||
|
||||
assert provider.enabled()
|
||||
assert result is data
|
||||
assert provider._parse_data(result).iloc[0] == 169.44 / 1_000_000
|
||||
request.assert_called_once_with(start_date="2026-08-01", force_update=True)
|
||||
+30
-22
@@ -6,11 +6,13 @@ from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
|
||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
|
||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
|
||||
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
|
||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||
LoadAkkudoktor,
|
||||
@@ -46,6 +48,7 @@ def forecast_providers():
|
||||
return [
|
||||
ElecPriceAkkudoktor(),
|
||||
ElecPriceEnergyCharts(),
|
||||
ElecPriceSMARD(),
|
||||
ElecPriceTibber(),
|
||||
ElecPriceFixed(),
|
||||
ElecPriceImport(),
|
||||
@@ -53,6 +56,7 @@ def forecast_providers():
|
||||
FeedInTariffAkkudoktor(),
|
||||
FeedInTariffFixed(),
|
||||
FeedInTariffImport(),
|
||||
FeedInTariffSMARD(),
|
||||
FeedInTariffTibber(),
|
||||
LoadAkkudoktor(),
|
||||
LoadAkkudoktorAdjusted(),
|
||||
@@ -102,28 +106,30 @@ def test_provider_sequence(prediction):
|
||||
"""Test the provider sequence is maintained in the Prediction instance."""
|
||||
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
|
||||
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
|
||||
assert isinstance(prediction.providers[2], ElecPriceTibber)
|
||||
assert isinstance(prediction.providers[3], ElecPriceFixed)
|
||||
assert isinstance(prediction.providers[4], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
|
||||
assert isinstance(prediction.providers[6], FeedInTariffAkkudoktor)
|
||||
assert isinstance(prediction.providers[7], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[8], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[9], FeedInTariffTibber)
|
||||
assert isinstance(prediction.providers[10], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[11], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[12], LoadVrm)
|
||||
assert isinstance(prediction.providers[13], LoadImport)
|
||||
assert isinstance(prediction.providers[14], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[15], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[16], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[17], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[18], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[19], PVForecastImport)
|
||||
assert isinstance(prediction.providers[20], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[21], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[22], WeatherOpenMeteo)
|
||||
assert isinstance(prediction.providers[23], WeatherImport)
|
||||
assert isinstance(prediction.providers[2], ElecPriceSMARD)
|
||||
assert isinstance(prediction.providers[3], ElecPriceTibber)
|
||||
assert isinstance(prediction.providers[4], ElecPriceFixed)
|
||||
assert isinstance(prediction.providers[5], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[6], FeedInTariffEnergyCharts)
|
||||
assert isinstance(prediction.providers[7], FeedInTariffAkkudoktor)
|
||||
assert isinstance(prediction.providers[8], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[9], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[10], FeedInTariffSMARD)
|
||||
assert isinstance(prediction.providers[11], FeedInTariffTibber)
|
||||
assert isinstance(prediction.providers[12], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[13], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[14], LoadVrm)
|
||||
assert isinstance(prediction.providers[15], LoadImport)
|
||||
assert isinstance(prediction.providers[16], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[17], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[18], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[19], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[20], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[21], PVForecastImport)
|
||||
assert isinstance(prediction.providers[22], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[23], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[24], WeatherOpenMeteo)
|
||||
assert isinstance(prediction.providers[25], WeatherImport)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
@@ -141,12 +147,14 @@ def test_prediction_repr(prediction):
|
||||
assert "Prediction([" in result
|
||||
assert "ElecPriceAkkudoktor" in result
|
||||
assert "ElecPriceEnergyCharts" in result
|
||||
assert "ElecPriceSMARD" in result
|
||||
assert "ElecPriceTibber" in result
|
||||
assert "ElecPriceFixed" in result
|
||||
assert "ElecPriceImport" in result
|
||||
assert "FeedInTariffFixed" in result
|
||||
assert "FeedInTariffAkkudoktor" in result
|
||||
assert "FeedInTariffImport" in result
|
||||
assert "FeedInTariffSMARD" in result
|
||||
assert "FeedInTariffTibber" in result
|
||||
assert "LoadAkkudoktor" in result
|
||||
assert "LoadVrm" in result
|
||||
|
||||
Reference in New Issue
Block a user