feat: electricity fee provider framework and generic providers (#1235)

Add new provider class for electricity fees providers.

Add the generic providers:
- ElecFeeFixed
- ElecFeeImport

The providers provide predictions for:

- elecfee_consumption_amt_wh:
  Total fixed fee for consumed energy per Wh [amount/Wh]. This is the accumulation of all
  fixed per-Wh fees payable on "consumed energy - such as network charge, concession fee,
  and electricity charge - into a single amount.
- elecfee_consumption_percent_amt:
  Total fixed surcharge on consumed energy, given as a percentage of the monetary amount
  already charged for that energy [%]. This is the accumulation of all percentage-based
  surcharges payable on top of the consumed-energy fee - such as VAT - into a single
  percentage. This is a percentage of the fee amount, not a per-Wh rate.
- elecfee_feedin_amt_wh:
  Total fixed deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of
  all fixed per-Wh charges deducted from feed-in energy - such as metering fees or
  grid-operator handling "charges - into a single amount. Applied after the percentage-based
  deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the
  raw price.
- elecfee_feedin_percent_amt:
  Total percentage deducted from the raw feed-in price (spot price) [%]. This is the
  accumulation of all percentage-based deductions payable on the feed-in tariff - such as a
  marketing or balancing fee retained by the aggregator - into a single percentage. It is
  applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather
  than adding a surcharge to it.

A new _apply_fee() method is added to the base class for ElecPrice and FeedInTariff to be used to
add the fees in a consistent way. Fees are taken from the active ElecFee provider and applied
to the raw prices given to the _apply_fee() method.

The optional application of fees is added to:

- ElecPriceAkkudoktor
- ElecPriceFixed
- ElecPriceEnergyCharts
- ElecPriceSMARD
- FeedInTariffEnergyCharts
- FeedInTariffFixed
- FeedInTariffSMARD

The import providers ElecPriceImport and FeedInTariffImport do not apply fees by intentention.

The following providers currently do not handle fees defined by ElecFee:

- ElecPriceTibber
- FeedInTariffAkkudoktor
- FeedInTariffDvhubOnline
- FeedInTariffTibber

The tests for this feature are either added or existing tests are extended.

The documentation was extended for the electricity fee provider settings.

Besides this feature further improvements are added:

* feat: add SMARD quarter-hour electricty price and feed-in tariff provider

* feat: to_series method for TimeWindows and ValueTimeWindows

  Additional to to_array the time window sequence can now also produce a pandas series.
  Test have been extended to cover the series generation.

* feat: use time windows in fixed feedin tariff provider

  Feedin tariff can now be configured by time windows - not a single value.

* feat: EOSdash select for PVLib inverters and modules

  Provide PVLib inverter and module names in config selection.

* feat: EOSdash lazy select for big option sets

  Add a new form for lazy selection of big option sets. Filtering and
  generation of the option set is done server-side.

* fix: use raw data for ETS/ median prediction

  Use to raw time series data for ETS/ median prediction to avoid interference
  by e.g. dynamic grid charges.

* fix: EOSdash config drops by type only on details resolve

  Drop configuration by type and path. Prevents dropping of configuration items
  with same type and level but different path.

* fix: EOSdash configuration section closes on update

  Open section if searching or if last update touched this category — including
  updates on deeply nested sub-fields.

* chore: make elecfeefixed, elecpricefixed and feedintarifffixed warn about no windows and default to 0

  Missining configuration creates default 0 value and a warning instead of an exception.

* fix: test setup for providers

  Reset db state on each test run.

* chore: improve config option naming for elecpricefixed.

* chore: adapt elecpricefixed test to changed time_windows naming

* chore: factorized common price provider helpers to priceabc.py

  Factorized common price provider helpers to priceabc.py. Add tests for these helpers.
  Reduce/ change testing of elecpriceabc.py and feedintariffabc.py to cover
  only specifics. Rest of testing is already covered by test_priceabc.py.

* chore: update version

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-08-23 02:26:16 +02:00
committed by GitHub
parent 6849b731b0
commit ba76087db9
71 changed files with 6584 additions and 1191 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ DOCKER_COMPOSE_DATA_DIR=${HOME}/.local/share/net.akkudoktor.eos
# -----------------------------------------------------------------------------
# Image / build
# -----------------------------------------------------------------------------
VERSION=0.3.0.dev2608160647074704
VERSION=0.3.0.dev2608221553448643
PYTHON_VERSION=3.13.15
# -----------------------------------------------------------------------------
+1 -1
View File
@@ -6,7 +6,7 @@
# the root directory (no add-on folder as usual).
name: "Akkudoktor-EOS"
version: "0.3.0.dev2608160647074704"
version: "0.3.0.dev2608221553448643"
slug: "eos"
description: "Akkudoktor-EOS add-on"
url: "https://github.com/Akkudoktor-EOS/EOS"
+1
View File
@@ -6,6 +6,7 @@
../_generated/configcache.md
../_generated/configdatabase.md
../_generated/configdevices.md
../_generated/configelecfee.md
../_generated/configelecprice.md
../_generated/configems.md
../_generated/configfeedintariff.md
+296
View File
@@ -0,0 +1,296 @@
## Electricity Price Prediction Configuration
<!-- pyml disable line-length -->
:::{table} elecfee
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees provider settings. |
| elecfeeimport | `EOS_ELECFEE__ELECFEEIMPORT` | `ElecFeeImportCommonSettings` | `rw` | `required` | Electricity fees import provider settings. |
| provider | `EOS_ELECFEE__PROVIDER` | `str | None` | `rw` | `None` | Electricity fee provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity fee provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
},
"providers": [
"ElecFeeFixed",
"ElecFeeImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecfee data import from file or JSON String
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeeimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import elecfee data from. |
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeeimport": {
"import_file_path": null,
"import_json": "{\"elecfee_consumption_amt_wh\": [0.0003384, 0.0003318, 0.0003284]}"
}
}
}
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for fixed electricity fees
This model defines a fixed electricity fee schedule using a sequence
of time windows. Each window specifies a time interval and the electricity
fee applicable during that interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed per-kWh electricty fee charged for consumed energy, accumulating all applicable fixed per-kWh charges (e.g. network charge, metering fee, concession fee) into a single amount [amount/kWh]. If not provided, no fixed per-kWh consumption fee is applied. |
| consumption_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed electricity surcharge applied as a percentage of the monetary amount already charged for consumed energy, accumulating all applicable percentage-based surcharges (e.g. VAT, electricity tax) into a single percentage [%]. This is a percentage of the fee amount, not a per-kWh rate. If not provided, no percentage-based consumption surcharge is applied. |
| feedin_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of all fixed per-Wh charges deducted from feed-in energy - such as metering fees or grid-operator handling charges - into a single amount. Applied after the percentage-based deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the raw price. If not provided, no fixed per-kWh feed-in fee is applied. |
| feedin_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total percentage deducted from the raw feed-in price (spot price) [%]. This is the accumulation of all percentage-based deductions payable on the feed-in tariff - such as a marketing or balancing fee retained by the aggregator - into a single percentage. It is applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather than adding a surcharge to it. If not provided, no percentage-based feed-in deduction is applied. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
+74 -93
View File
@@ -7,14 +7,14 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `float | None` | `rw` | `None` | Electricity price charges [amount/kWh]. Will be added to variable market price. |
| akkudoktor | `EOS_ELECPRICE__AKKUDOKTOR` | `ElecPriceAkkudoktorCommonSettings` | `rw` | `required` | Akkudoktor electricity price provider settings. |
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Electricity price import provider settings. |
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `str | None` | `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` | SMARD electricity price provider settings. |
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `float | None` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
@@ -27,10 +27,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -41,6 +40,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -59,10 +62,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -73,6 +75,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -82,6 +88,7 @@
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceImport",
"ElecPriceSMARD",
"ElecPriceTibber"
]
}
@@ -120,6 +127,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 -->
@@ -180,89 +218,6 @@
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- 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": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- 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": {
"elecpricefixed": {
"time_windows": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common configuration settings for fixed electricity pricing
This model defines a fixed electricity price schedule using a sequence
@@ -276,7 +231,7 @@ price applicable during that interval.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| time_windows | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
| elecprice_marketprice_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
:::
<!-- pyml enable line-length -->
@@ -289,7 +244,7 @@ price applicable during that interval.
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
@@ -314,3 +269,29 @@ price applicable during that interval.
}
```
<!-- pyml enable line-length -->
### Common configuration settings for Akkodoktor electricity pricing
<!-- pyml disable line-length -->
:::{table} elecprice::akkudoktor
: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
{
"elecprice": {
"akkudoktor": {}
}
}
```
<!-- pyml enable line-length -->
+32 -5
View File
@@ -95,12 +95,32 @@
"home_appliances": [],
"max_home_appliances": 1
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -111,6 +131,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -124,7 +148,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -136,7 +162,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
},
"general": {
"config_save_mode": "AUTOMATIC",
+58 -5
View File
@@ -13,6 +13,7 @@
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `str | None` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
:::
<!-- pyml enable line-length -->
@@ -26,7 +27,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -38,7 +41,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
}
}
```
@@ -54,7 +58,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -67,12 +73,14 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {},
"providers": [
"FeedInTariffAkkudoktor",
"FeedInTariffDvhubOnline",
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTariffImport",
"FeedInTariffSMARD",
"FeedInTariffTibber"
]
}
@@ -80,6 +88,32 @@
```
<!-- pyml enable line-length -->
### Settings for SMARD feed-in prices shared with ``elecprice.smard``
<!-- pyml disable line-length -->
:::{table} feedintariff::smard
: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": {
"smard": {}
}
}
```
<!-- pyml enable line-length -->
### Common settings for feed in tariff data import from file or JSON string
<!-- pyml disable line-length -->
@@ -120,7 +154,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| feed_in_tariff_kwh | `float | None` | `rw` | `None` | Electricity price feed in tariff [amount/kWh]. |
| feed_in_tariff_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the electricity feed in tariff [amount/kWh]. If not provided, no fixed feed in tariff is applied. |
:::
<!-- pyml enable line-length -->
@@ -133,7 +167,26 @@
{
"feedintariff": {
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.028
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.034
}
]
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.3.0.dev2608160647074704`
**Version**: `v0.3.0.dev2608221553448643`
<!-- 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.
+212 -25
View File
@@ -121,12 +121,107 @@ If no keys are displayed, or if the ones you need are missing, it indicates that
lacks the necessary prediction provider settings. You can configure prediction providers by using
the **PUT** `/v1/config` endpoint. You may save your configuration to the EOS configuration file.
## Electricity Fee Prediction
Prediction keys:
- `elecfee_consumption_amt_wh`: Total fixed fee for consumed energy per Wh (amount/Wh),
accumulating all fixed per-Wh fees payable on consumed energy - such as network charge,
concession fee, and electricity charge - into a single amount.
- `elecfee_consumption_amt_kwh`: Total fixed fee for consumed energy per kWh (amount/kWh),
calculated from `elecfee_consumption_amt_wh`.
- `elecfee_consumption_percent_amt`: Total fixed surcharge on consumed energy, given as a
percentage of the monetary amount already charged for that energy (%), accumulating all
percentage-based surcharges - such as VAT - into a single percentage. Applied after
`elecfee_consumption_amt_wh`, i.e. it is calculated on the raw price plus the per-Wh fee.
- `elecfee_feedin_amt_wh`: Total fixed fee for feed-in energy per Wh (amount/Wh), accumulating
all fixed per-Wh fees payable on feed-in energy - such as network charge and concession fee -
into a single amount. Applied after `elecfee_feedin_percent_amt`, i.e. it is deducted from
the price as a flat per-Wh amount once the percentage deduction has already been applied.
- `elecfee_feedin_amt_kwh`: Total fixed fee for feed-in energy per kWh (amount/kWh),
calculated from `elecfee_feedin_amt_wh`.
- `elecfee_feedin_percent_amt`: Total fixed deduction on feed-in energy, given as a percentage
of the raw feed-in price (spot price) for that energy (%), accumulating all percentage-based
deductions - such as a marketing or balancing fee retained by the aggregator - into a single
percentage. Applied to the raw price before `elecfee_feedin_amt_wh` is subtracted.
Configuration options:
- `elecfee`: Electricity fee configuration.
- `provider`: Electricity fee provider id of provider to be used.
- `ElecFeeFixed`: Caluclates from configured time window fees.
- `ElecFeeImport`: Imports from a file or JSON string or by endpoint data provision.
- `elecfeefixed.consumption_amt_kwh.windows`: Time windows defining the total fixed per-kWh
fee for consumed energy, accumulating fees such as network charge, metering fee, and
concession fee into a single amount (amount/kWh).
- `elecfeefixed.consumption_percent_amt.windows`: Time windows defining the total fixed
surcharge on consumed energy, given as a percentage of the amount already charged,
accumulating surcharges such as VAT into a single percentage (%).
- `elecfeefixed.feedin_amt_kwh.windows`: Time windows defining the total fixed per-kWh fee
or credit for feed-in energy, accumulating fees such as network charge and metering fee
into a single amount (amount/kWh).
- `elecfeefixed.feedin_percent_amt.windows`: Time windows defining the total fixed
deduction on feed-in energy, given as a percentage of the raw feed-in price (spot price),
accumulating deductions such as a service charge into a single percentage (%).
- `elecfeeimport.import_file_path`: Path to the file to import electricity fee forecast data from.
- `elecfeeimport.import_json`: JSON string, dictionary of electricity fee forecast value lists.
### ElecFeeFixed Provider
The `ElecFeeFixed` provider calculates the electricity fees from the configuration
of electricity fee time windows set up by the user.
### ElecFeeImport Provider
The `ElecFeeImport` provider is designed to import electricity prices from:
- A file or a JSON string (primarily for initialization). The data source can be given in the
`import_file_path` or `import_json` configuration option.
- The **PUT** `/v1/prediction/import/ElecFeeImport` endpoint (recommended for dynamic updates).
The prediction keys for the electricity fee forecast data are:
- `elecfee_consumption_amt_wh`: Total fixed fee for consumed energy per Wh (amount/Wh),
accumulating all fixed per-Wh fees payable on consumed energy - such as network charge,
concession fee, and electricity charge - into a single amount.
- `elecfee_consumption_percent_amt`: Total fixed surcharge on consumed energy, given as a
percentage of the monetary amount already charged for that energy (%), accumulating all
percentage-based surcharges - such as VAT - into a single percentage.
- `elecfee_feedin_amt_wh`: Total fixed fee for feed-in energy per Wh (amount/Wh), accumulating
all fixed per-Wh fees payable on feed-in energy - such as network charge and concession fee -
into a single amount.
- `elecfee_feedin_percent_amt`: Total fixed deduction on feed-in energy, given as a percentage
of the raw feed-in price (spot price) for that energy (%), accumulating all percentage-based
deductions into a single percentage.
The electricity fee forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>.
An external entity may update the file or JSON string whenever new prediction data becomes
available. However, for production use or regular updates, you should prefer the **PUT** endpoint
over file or JSON string based imports.
:::{admonition} Warning
:class: warning
Be aware that providing dynamic values via a file and/or JSON string **alongside other value
sources** can lead to unintended data overwrites. Moreover, after a restart, even outdated values
from the configuration may be reloaded. To avoid these issues, use the **PUT** endpoint for live
data and rely on file/JSON imports only for initial setup.
:::
## Electricity Price Prediction
Prediction keys:
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
- `elecprice_marketprice_kwh`: Electricity market price per kWh (€/kWh).
- `elecprice_marketprice_raw_wh`: Raw electricity market price per Wh, always excluding fees
(amount/Wh).
- `elecprice_marketprice_wh`: Electricity market price per Wh, including fees if configured
(amount/Wh).
- `elecprice_marketprice_kwh`: Electricity market price per kWh (amount/kWh), calculated from
`elecprice_marketprice_wh`.
Configuration options:
@@ -139,20 +234,29 @@ Configuration options:
- `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).
- `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.
- `elecpricefixed.elecprice_marketprice_amt_kwh.windows`: The time windows with associated
electricity prices.
- `elecpriceimport.import_file_path`: Path to the file to import electricity price forecast data from.
- `elecpriceimport.import_json`: JSON string, dictionary of electricity price forecast value lists.
- `energycharts.bidding_zone`: Bidding zone Energy Charts shall provide price data for.
Fees:
- If `fees` are given by the ElecFee prediction, the electricity fees are added to the raw market
price and stored in `elecprice_marketprice_wh`.
- The resulting price is calculated as:
`(raw market price + elecfee_consumption_amt_wh) * (100 + elecfee_consumption_percent_amt) / 100`
where `elecfee_consumption_amt_wh` and `elecfee_consumption_percent_amt` are given by the
ElecFee prediction.
### ElecPriceAkkudoktor Provider
The `ElecPriceAkkudoktor` provider retrieves electricity prices directly from **Akkudoktor.net**,
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
prices by extrapolating historical price data combined with the most recent actual prices obtained
from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
option are added.
from Akkudoktor.net.
Fees are applied if given by the `ElecFee` prediction.
### ElecPriceEnergyCharts Provider
@@ -164,19 +268,20 @@ forecasting by combining real-time market data with historical price trends.
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data
and the latest available market values.
Charges and VAT
Fees are applied if given by the `ElecFee` prediction.
- 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).
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
:::{admonition} Note
:class: note
For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
:::
### ElecPriceFixed Provider
The `ElecPriceFixed` provider calculates the day-ahead electricity market prices from the configuration
of electricity price time windows set up by the user.
Fees are applied if given by the `ElecFee` prediction.
### ElecPriceImport Provider
The `ElecPriceImport` provider is designed to import electricity prices from:
@@ -185,9 +290,11 @@ The `ElecPriceImport` provider is designed to import electricity prices from:
`import_file_path` or `import_json` configuration option.
- The **PUT** `/v1/prediction/import/ElecPriceImport` endpoint (recommended for dynamic updates).
The prediction key for the electricity price forecast data is:
The prediction keys for the electricity price forecast data are:
- `elecprice_marketprice_wh`: Electricity market price per Wh (€/Wh).
- `elecprice_marketprice_raw_wh`: Raw electricity market price per Wh, always excluding fees
(amount/Wh).
- `elecprice_marketprice_wh`: Electricity market price per Wh, including fees if wished (amount/Wh).
The electricity price forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>.
@@ -204,12 +311,38 @@ from the configuration may be reloaded. To avoid these issues, use the **PUT** e
data and rely on file/JSON imports only for initial setup.
:::
:::{admonition} Note
:class: note
No fees are applied, even if given by the `ElecFee` prediction.
:::
### 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.
Fees are applied if given by the ElecFee prediction.
The same raw SMARD series is also available as `FeedInTariffSMARD` for direct-marketing feed-in
revenue.
### ElecPriceTibber Provider
:::{admonition} Warning
:class: warning
`elecprice_marketprice_raw_wh` is currently not filled by the `ElecPriceTibber` prediction.
:::
## Feed In Tariff Prediction
Prediction keys:
- `feed_in_tariff_wh`: Feed in tarif per Wh (€/Wh).
- `feed_in_tariff_kwh`: Feed in tarif per kWh (€/kWh)
- `feed_in_tariff_raw_wh`: Feed in tarif per Wh, always excluding fees (€/Wh).
- `feed_in_tariff_wh`: Feed in tarif per Wh, including fees if configured (€/Wh).
- `feed_in_tariff_kwh`: Feed in tarif per kWh (€/kWh), calculated from `feed_in_tariff_wh`.
Configuration options:
@@ -226,16 +359,26 @@ Configuration options:
- `FeedInTariffTibber`: Retrieves Tibber's native quarter-hour energy-price component.
- `energycharts.bidding_zone`: Bidding zone Energy Charts shall provide feed-in tariff for.
- `feedintarifffixed.feed_in_tariff_kwh`: Fixed feed in tariff (€/kWh).
- `feedintarifffixed.feed_in_tariff_amt_kwh.windows`: The time windows with associated
fixed feed-in tariff (Amount/kWh).
- `feedintariffimport.import_file_path`: Path to the file to import feed in tariff forecast data from.
- `feedintariffimport.import_json`: JSON string, dictionary of feed in tariff value lists.
Fees:
- If `fees` are given by the ElecFee prediction, the electricity fees are subtracted from the
raw feed-in tariff and stored in `feed_in_tariff_wh`.
- The resulting tariff is calculated as:
`raw feed in tariff * (100 - elecfee_feedin_percent_amt) / 100 - elecfee_feedin_amt_wh`
where `elecfee_feedin_amt_wh` and `elecfee_feedin_percent_amt` are given by the
ElecFee prediction.
### FeedInTariffAkkudoktor Provider
The `FeedInTariffAkkudoktor` provider uses raw day-ahead market prices from
`https://api.akkudoktor.net/prices` as `feed_in_tariff_wh`. It does not add electricity import
charges or VAT. Published prices are extended to the configured prediction horizon with the same
seasonal ETS or median fallback used by the Akkudoktor electricity-price provider.
`https://api.akkudoktor.net/prices` as `feed_in_tariff_wh`. Published prices are extended to
the configured prediction horizon with the same seasonal ETS or median fallback used by the
Akkudoktor electricity-price provider.
The Akkudoktor endpoint currently forwards hourly market prices from aWATTar. With a 15-minute
optimization interval, EOS holds each hourly price constant for its four quarter-hour slots. This
@@ -249,11 +392,25 @@ keeps the slot grid consistent but does not create genuine quarter-hour market p
}
```
:::{admonition} Warning
:class: warning
`feed_in_tariff_raw_wh` is currently not filled by the `FeedInTariffAkkudoktor` prediction.
:::
:::{admonition} Note
:class: note
No fees are applied, even if given by the `ElecFee` prediction.
:::
### FeedInTariffDvhubOnline Provider
TBD
### FeedInTariffEnergyCharts Provider
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
feed-in tariff. It stores prices in `feed_in_tariff_wh` without adding electricity import charges
or VAT. The data is loaded from the Energy-Charts `/price` endpoint for the configured bidding
feed-in tariff. It stores raw prices in `feed_in_tariff_raw_wh` without adding electricity fees.
The data is loaded from the Energy-Charts `/price` endpoint for the configured bidding
zone. The native Energy-Charts resolution, including quarter-hour data, is retained.
Energy-Charts usually supplies prices only for the published day-ahead period. If that data does
@@ -269,6 +426,8 @@ four values per hour. Values already supplied by Energy-Charts are kept unchange
future slots after the last published price are forecast. Consequently, a 15-minute optimization
uses four forecast values per hour without converting them to hourly averages.
Fees are applied if given by the `ElecFee` prediction.
Example configuration:
```json
@@ -282,6 +441,13 @@ Example configuration:
}
```
### FeedInTariffFixed Provider
The `FeedInTariffFixed` provider calculates the day-ahead feed-in tariffs from the configuration
of feed-in tariff time windows set up by the user.
Fees are applied if given by the `ElecFee` prediction.
### FeedInTariffImport Provider
The `FeedInTariffImport` provider is designed to import feed in tariff prices from:
@@ -290,9 +456,10 @@ The `FeedInTariffImport` provider is designed to import feed in tariff prices fr
`import_file_path` or `import_json` configuration option.
- The **PUT** `/v1/prediction/import/FeedInTariffImport` endpoint (recommended for dynamic updates).
The prediction key for the feed in tariff price forecast data is:
The prediction keys for the feed in tariff price forecast data are:
- `feed_in_tariff_wh`: Feed in tariff price per Wh (€/Wh).
- `feed_in_tariff_raw_wh`: Feed in tarif per Wh, always excluding fees (€/Wh).
- `feed_in_tariff_wh`: Feed in tarif per Wh, including fees if wished (€/Wh).
The feed in tariff price forecast data must be provided in one of the formats described in
<project:#prediction-import-providers>.
@@ -309,6 +476,21 @@ from the configuration may be reloaded. To avoid these issues, use the **PUT** e
data and rely on file/JSON imports only for initial setup.
:::
:::{admonition} Note
:class: note
No fees are applied, even if given by the `ElecFee` prediction.
:::
### FeedInTariffSMARD Provider
he `FeedInTariffSMARD` 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.
Fees are applied if given by the `ElecFee` prediction.
### FeedInTariffTibber Provider
The `FeedInTariffTibber` provider requests `priceInfo` and `priceInfoRange` with
@@ -328,6 +510,11 @@ are needed.
}
```
:::{admonition} Warning
:class: warning
`feed_in_tariff_raw_wh` is currently not filled by the `FeedInTariffTibber` prediction.
:::
## Load Prediction
Prediction keys:
+393 -83
View File
@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.3.0.dev2608160647074704"
"version": "v0.3.0.dev2608221553448643"
},
"paths": {
"/v1/admin/cache/clear": {
@@ -3308,6 +3308,9 @@
"prediction": {
"$ref": "#/components/schemas/PredictionCommonSettings"
},
"elecfee": {
"$ref": "#/components/schemas/ElecFeeCommonSettings-Output"
},
"elecprice": {
"$ref": "#/components/schemas/ElecPriceCommonSettings-Output"
},
@@ -4035,6 +4038,280 @@
"title": "DevicesCommonSettings",
"description": "Base configuration for devices simulation settings."
},
"ElecFeeCommonSettings-Input": {
"properties": {
"provider": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Provider",
"description": "Electricity fee provider id of provider to be used.",
"examples": [
"ElecFeeFixed"
]
},
"elecfeefixed": {
"$ref": "#/components/schemas/ElecFeeFixedCommonSettings-Input",
"description": "Fixed electricity fees provider settings."
},
"elecfeeimport": {
"$ref": "#/components/schemas/ElecFeeImportCommonSettings",
"description": "Electricity fees import provider settings."
}
},
"type": "object",
"title": "ElecFeeCommonSettings",
"description": "Electricity Price Prediction Configuration."
},
"ElecFeeCommonSettings-Output": {
"properties": {
"provider": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Provider",
"description": "Electricity fee provider id of provider to be used.",
"examples": [
"ElecFeeFixed"
]
},
"elecfeefixed": {
"$ref": "#/components/schemas/ElecFeeFixedCommonSettings-Output",
"description": "Fixed electricity fees provider settings."
},
"elecfeeimport": {
"$ref": "#/components/schemas/ElecFeeImportCommonSettings",
"description": "Electricity fees import provider settings."
},
"providers": {
"items": {
"type": "string"
},
"type": "array",
"title": "Providers",
"description": "Available electricity fee provider ids.",
"readOnly": true
}
},
"type": "object",
"required": [
"providers"
],
"title": "ElecFeeCommonSettings",
"description": "Electricity Price Prediction Configuration."
},
"ElecFeeFixedCommonSettings-Input": {
"properties": {
"consumption_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the total fixed per-kWh electricty fee charged for consumed energy, accumulating all applicable fixed per-kWh charges (e.g. network charge, metering fee, concession fee) into a single amount [amount/kWh]. If not provided, no fixed per-kWh consumption fee is applied.",
"examples": [
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.00288
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.0034
}
]
}
]
},
"consumption_percent_amt": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the total fixed electricity surcharge applied as a percentage of the monetary amount already charged for consumed energy, accumulating all applicable percentage-based surcharges (e.g. VAT, electricity tax) into a single percentage [%]. This is a percentage of the fee amount, not a per-kWh rate. If not provided, no percentage-based consumption surcharge is applied.",
"examples": [
{
"windows": [
{
"duration": "24 hours",
"start_time": "00:00",
"value": 19.0
}
]
}
]
},
"feedin_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the total deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of all fixed per-Wh charges deducted from feed-in energy - such as metering fees or grid-operator handling charges - into a single amount. Applied after the percentage-based deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the raw price. If not provided, no fixed per-kWh feed-in fee is applied.",
"examples": [
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.00288
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.0034
}
]
}
]
},
"feedin_percent_amt": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the total percentage deducted from the raw feed-in price (spot price) [%]. This is the accumulation of all percentage-based deductions payable on the feed-in tariff - such as a marketing or balancing fee retained by the aggregator - into a single percentage. It is applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather than adding a surcharge to it. If not provided, no percentage-based feed-in deduction is applied.",
"examples": [
{
"windows": [
{
"duration": "24 hours",
"start_time": "00:00",
"value": 19.0
}
]
}
]
}
},
"type": "object",
"title": "ElecFeeFixedCommonSettings",
"description": "Common settings for fixed electricity fees.\n\nThis model defines a fixed electricity fee schedule using a sequence\nof time windows. Each window specifies a time interval and the electricity\nfee applicable during that interval."
},
"ElecFeeFixedCommonSettings-Output": {
"properties": {
"consumption_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the total fixed per-kWh electricty fee charged for consumed energy, accumulating all applicable fixed per-kWh charges (e.g. network charge, metering fee, concession fee) into a single amount [amount/kWh]. If not provided, no fixed per-kWh consumption fee is applied.",
"examples": [
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.00288
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.0034
}
]
}
]
},
"consumption_percent_amt": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the total fixed electricity surcharge applied as a percentage of the monetary amount already charged for consumed energy, accumulating all applicable percentage-based surcharges (e.g. VAT, electricity tax) into a single percentage [%]. This is a percentage of the fee amount, not a per-kWh rate. If not provided, no percentage-based consumption surcharge is applied.",
"examples": [
{
"windows": [
{
"duration": "24 hours",
"start_time": "00:00",
"value": 19.0
}
]
}
]
},
"feedin_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the total deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of all fixed per-Wh charges deducted from feed-in energy - such as metering fees or grid-operator handling charges - into a single amount. Applied after the percentage-based deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the raw price. If not provided, no fixed per-kWh feed-in fee is applied.",
"examples": [
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.00288
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.0034
}
]
}
]
},
"feedin_percent_amt": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the total percentage deducted from the raw feed-in price (spot price) [%]. This is the accumulation of all percentage-based deductions payable on the feed-in tariff - such as a marketing or balancing fee retained by the aggregator - into a single percentage. It is applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather than adding a surcharge to it. If not provided, no percentage-based feed-in deduction is applied.",
"examples": [
{
"windows": [
{
"duration": "24 hours",
"start_time": "00:00",
"value": 19.0
}
]
}
]
}
},
"type": "object",
"title": "ElecFeeFixedCommonSettings",
"description": "Common settings for fixed electricity fees.\n\nThis model defines a fixed electricity fee schedule using a sequence\nof time windows. Each window specifies a time interval and the electricity\nfee applicable during that interval."
},
"ElecFeeImportCommonSettings": {
"properties": {
"import_file_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"format": "path"
},
{
"type": "null"
}
],
"title": "Import File Path",
"description": "Path to the file to import elecfee data from.",
"examples": [
null,
"/path/to/prices.json"
]
},
"import_json": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Import Json",
"description": "JSON string, dictionary of electricity fee forecast value lists.",
"examples": [
"{\"elecfee_consumption_amt_wh\": [0.0003384, 0.0003318, 0.0003284]}"
]
}
},
"type": "object",
"title": "ElecFeeImportCommonSettings",
"description": "Common settings for elecfee data import from file or JSON String."
},
"ElecPriceAkkudoktorCommonSettings": {
"properties": {},
"type": "object",
"title": "ElecPriceAkkudoktorCommonSettings",
"description": "Common configuration settings for Akkodoktor electricity pricing."
},
"ElecPriceCommonSettings-Input": {
"properties": {
"provider": {
@@ -4052,38 +4329,9 @@
"ElecPriceAkkudoktor"
]
},
"charges_kwh": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Charges Kwh",
"description": "Electricity price charges [amount/kWh]. Will be added to variable market price.",
"examples": [
0.21
]
},
"vat_rate": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Vat Rate",
"description": "VAT rate factor applied to electricity price when charges are used.",
"default": 1.19,
"examples": [
1.19
]
"akkudoktor": {
"$ref": "#/components/schemas/ElecPriceAkkudoktorCommonSettings",
"description": "Akkudoktor electricity price provider settings."
},
"elecpricefixed": {
"$ref": "#/components/schemas/ElecPriceFixedCommonSettings-Input",
@@ -4097,6 +4345,10 @@
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings."
},
"smard": {
"$ref": "#/components/schemas/ElecPriceSMARDCommonSettings",
"description": "SMARD electricity price provider settings."
},
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
@@ -4123,38 +4375,9 @@
"ElecPriceAkkudoktor"
]
},
"charges_kwh": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Charges Kwh",
"description": "Electricity price charges [amount/kWh]. Will be added to variable market price.",
"examples": [
0.21
]
},
"vat_rate": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Vat Rate",
"description": "VAT rate factor applied to electricity price when charges are used.",
"default": 1.19,
"examples": [
1.19
]
"akkudoktor": {
"$ref": "#/components/schemas/ElecPriceAkkudoktorCommonSettings",
"description": "Akkudoktor electricity price provider settings."
},
"elecpricefixed": {
"$ref": "#/components/schemas/ElecPriceFixedCommonSettings-Output",
@@ -4168,6 +4391,10 @@
"$ref": "#/components/schemas/ElecPriceEnergyChartsCommonSettings",
"description": "Energy Charts provider settings."
},
"smard": {
"$ref": "#/components/schemas/ElecPriceSMARDCommonSettings",
"description": "SMARD electricity price provider settings."
},
"tibber": {
"$ref": "#/components/schemas/ElecPriceTibberCommonSettings",
"description": "Tibber electricity price provider settings."
@@ -4206,7 +4433,7 @@
},
"ElecPriceFixedCommonSettings-Input": {
"properties": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied.",
"examples": [
@@ -4233,7 +4460,7 @@
},
"ElecPriceFixedCommonSettings-Output": {
"properties": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied.",
"examples": [
@@ -4300,6 +4527,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": {
@@ -5034,7 +5288,7 @@
]
},
"feedintarifffixed": {
"$ref": "#/components/schemas/FeedInTariffFixedCommonSettings",
"$ref": "#/components/schemas/FeedInTariffFixedCommonSettings-Input",
"description": "Fixed feed in tariff provider settings."
},
"feedintariffimport": {
@@ -5048,6 +5302,10 @@
"energycharts": {
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings",
"description": "EnergyCharts feed in tariff provider settings."
},
"smard": {
"$ref": "#/components/schemas/FeedInTariffSMARDCommonSettings",
"description": "SMARD feed in tariff provider settings."
}
},
"type": "object",
@@ -5073,7 +5331,7 @@
]
},
"feedintarifffixed": {
"$ref": "#/components/schemas/FeedInTariffFixedCommonSettings",
"$ref": "#/components/schemas/FeedInTariffFixedCommonSettings-Output",
"description": "Fixed feed in tariff provider settings."
},
"feedintariffimport": {
@@ -5088,6 +5346,10 @@
"$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings",
"description": "EnergyCharts feed in tariff provider settings."
},
"smard": {
"$ref": "#/components/schemas/FeedInTariffSMARDCommonSettings",
"description": "SMARD feed in tariff provider settings."
},
"providers": {
"items": {
"type": "string"
@@ -5145,22 +5407,53 @@
"title": "FeedInTariffEnergyChartsCommonSettings",
"description": "Common settings for Energy-Charts feed-in tariff provider."
},
"FeedInTariffFixedCommonSettings": {
"FeedInTariffFixedCommonSettings-Input": {
"properties": {
"feed_in_tariff_kwh": {
"anyOf": [
{
"type": "number",
"minimum": 0.0
},
{
"type": "null"
}
],
"title": "Feed In Tariff Kwh",
"description": "Electricity price feed in tariff [amount/kWh].",
"feed_in_tariff_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
"description": "Sequence of time windows defining the electricity feed in tariff [amount/kWh]. If not provided, no fixed feed in tariff is applied.",
"examples": [
0.078
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.028
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.034
}
]
}
]
}
},
"type": "object",
"title": "FeedInTariffFixedCommonSettings",
"description": "Common settings for elecprice fixed price."
},
"FeedInTariffFixedCommonSettings-Output": {
"properties": {
"feed_in_tariff_amt_kwh": {
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
"description": "Sequence of time windows defining the electricity feed in tariff [amount/kWh]. If not provided, no fixed feed in tariff is applied.",
"examples": [
{
"windows": [
{
"duration": "8 hours",
"start_time": "00:00",
"value": 0.028
},
{
"duration": "16 hours",
"start_time": "08:00",
"value": 0.034
}
]
}
]
}
},
@@ -5210,6 +5503,12 @@
"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``."
},
"ForecastResponse": {
"properties": {
"temperature": {
@@ -11625,6 +11924,17 @@
],
"description": "Prediction Settings"
},
"elecfee": {
"anyOf": [
{
"$ref": "#/components/schemas/ElecFeeCommonSettings-Input"
},
{
"type": "null"
}
],
"description": "Electricity Fee Settings"
},
"elecprice": {
"anyOf": [
{
+6 -1
View File
@@ -43,6 +43,7 @@ from akkudoktoreos.core.version import __version__
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
from akkudoktoreos.prediction.elecfee import ElecFeeCommonSettings
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.feedintariff import FeedInTariffCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
@@ -264,6 +265,9 @@ class SettingsEOS(pydantic_settings.BaseSettings, PydanticModelNestedValueMixin)
prediction: Optional[PredictionCommonSettings] = Field(
default=None, json_schema_extra={"description": "Prediction Settings"}
)
elecfee: Optional[ElecFeeCommonSettings] = Field(
default=None, json_schema_extra={"description": "Electricity Fee Settings"}
)
elecprice: Optional[ElecPriceCommonSettings] = Field(
default=None, json_schema_extra={"description": "Electricity Price Settings"}
)
@@ -312,6 +316,7 @@ class SettingsEOSDefaults(SettingsEOS):
measurement: MeasurementCommonSettings = Field(default_factory=MeasurementCommonSettings)
optimization: OptimizationCommonSettings = Field(default_factory=OptimizationCommonSettings)
prediction: PredictionCommonSettings = Field(default_factory=PredictionCommonSettings)
elecfee: ElecFeeCommonSettings = Field(default_factory=ElecFeeCommonSettings)
elecprice: ElecPriceCommonSettings = Field(default_factory=ElecPriceCommonSettings)
feedintariff: FeedInTariffCommonSettings = Field(default_factory=FeedInTariffCommonSettings)
load: LoadCommonSettings = Field(default_factory=LoadCommonSettings)
@@ -621,6 +626,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
# This should not happen
raise RuntimeError("Config file path not set.")
settings = {}
try:
backup_file = config_file.with_suffix(f".{to_datetime(as_string='YYYYMMDDHHmmss')}")
if migrate_config_file(config_file, backup_file):
@@ -632,7 +638,6 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
logger.error(
f"Error reading config file '{config_file}' (falling back to default config): {ex}"
)
settings = {}
return settings
+170
View File
@@ -7,6 +7,7 @@ from enum import StrEnum
from typing import Any, ClassVar, Iterator, Optional, Union
import numpy as np
import pandas as pd
import pendulum
from babel.dates import get_day_names
from pydantic import Field, field_serializer, field_validator, model_validator
@@ -668,6 +669,76 @@ class TimeWindowSequence(SettingsBaseModel):
return np.array(result, dtype=np.float64)
def to_series(
self,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
dropna: bool = True,
boundary: str = "context",
align_to_interval: bool = True,
) -> pd.Series:
"""Return a pandas Series indicating window coverage over a time grid.
The time grid is constructed from ``start_datetime`` to ``end_datetime``
(exclusive) in steps of ``interval``. Each element is ``1.0`` when the
corresponding step falls inside any window in this sequence, and ``0.0``
otherwise.
Args:
start_datetime: First step of the time grid (inclusive).
end_datetime: Upper bound of the time grid (exclusive).
interval: Fixed step size between consecutive grid points.
dropna: Unused for ``TimeWindowSequence`` (no NaN values are
produced). Accepted for signature compatibility.
boundary: Controls range enforcement. Only ``"context"`` is
currently supported.
align_to_interval: When ``True``, ``start_datetime`` is floored to
the nearest interval boundary in wall-clock time before
generating the grid. The timezone (or naivety) of
``start_datetime`` is preserved exactly.
Returns:
``pd.Series`` with a ``DatetimeIndex`` and ``float64`` values.
``1.0`` means the timestamp is inside a window; ``0.0`` means it
is not.
Raises:
ValueError: If ``boundary`` is not ``"context"``.
"""
if boundary != "context":
raise ValueError(f"Unsupported boundary {boundary!r}. Only 'context' is supported.")
interval_s = interval.total_seconds()
if align_to_interval and interval_s > 0:
# Floor purely in wall-clock seconds so the timezone (or naivety)
# of start_datetime is never touched and no UTC conversion occurs.
wall_s = (
start_datetime.hour * 3600
+ start_datetime.minute * 60
+ start_datetime.second
+ start_datetime.microsecond / 1_000_000
)
remainder_s = wall_s % interval_s
if remainder_s:
start_datetime = start_datetime.subtract(seconds=remainder_s)
timestamps: list[DateTime] = []
values: list[float] = []
current = start_datetime
while current < end_datetime:
timestamps.append(current)
values.append(1.0 if self.contains(current) else 0.0)
current = current.add(seconds=interval_s)
return pd.Series(
values,
index=pd.DatetimeIndex(timestamps),
dtype=np.float64,
)
def add_window(self, window: TimeWindow) -> None:
"""Add a new time window to the sequence.
@@ -861,3 +932,102 @@ class ValueTimeWindowSequence(TimeWindowSequence):
current = current.add(seconds=interval_s)
return np.array(result, dtype=np.float64)
def to_series(
self,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
dropna: bool = True,
boundary: str = "context",
align_to_interval: bool = True,
) -> pd.Series:
"""Return a pandas Series of window values over a time grid.
The time grid is constructed from ``start_datetime`` to ``end_datetime``
(exclusive) in steps of ``interval``, matching the ``key_to_series``
signature used by the prediction store. Each element holds the
``value`` of the first matching window at that step, ``0.0`` when no
window matches, or ``NaN`` when the matching window has ``value=None``
and ``dropna=False``.
When ``dropna=True``, steps whose matching window has ``value=None`` are
omitted from the resulting Series entirely, including their timestamps.
Args:
start_datetime: First step of the time grid (inclusive).
end_datetime: Upper bound of the time grid (exclusive).
interval: Fixed step size between consecutive grid points.
dropna: When ``True``, steps whose matching window carries
``value=None`` are dropped from the Series. When ``False``,
those steps emit ``NaN``.
boundary: Controls range enforcement. Only ``"context"`` is
currently supported; the output is always clipped to
``[start_datetime, end_datetime)``.
align_to_interval: When ``True``, ``start_datetime`` is floored to
the nearest interval boundary in wall-clock time before
generating the grid. The timezone (or naivety) of
``start_datetime`` is preserved exactly — no UTC conversion
is performed. When ``False``, ``start_datetime`` is used as-is.
Returns:
``pd.Series`` with a ``DatetimeIndex`` and ``float64`` values.
Positive values are window values; ``0.0`` means no window matched;
``NaN`` means a window matched but its value was ``None`` (only when
``dropna=False``).
Raises:
ValueError: If ``boundary`` is not ``"context"``.
"""
if boundary != "context":
raise ValueError(f"Unsupported boundary {boundary!r}. Only 'context' is supported.")
interval_s = interval.total_seconds()
if align_to_interval and interval_s > 0:
# Floor purely in wall-clock seconds so the timezone (or naivety)
# of start_datetime is never touched and no UTC conversion occurs.
# This is correct regardless of the machine's local timezone.
wall_s = (
start_datetime.hour * 3600
+ start_datetime.minute * 60
+ start_datetime.second
+ start_datetime.microsecond / 1_000_000
)
remainder_s = wall_s % interval_s
if remainder_s:
start_datetime = start_datetime.subtract(seconds=remainder_s)
timestamps: list[DateTime] = []
result: list[float] = []
current = start_datetime
while current < end_datetime:
step_value: Optional[float] = None
matched = False
for window in self.windows:
if window.contains(current):
step_value = window.value
matched = True
break
if not matched:
timestamps.append(current)
result.append(0.0)
elif step_value is None:
if not dropna:
timestamps.append(current)
result.append(float("nan"))
# else: omit this step and its timestamp
else:
timestamps.append(current)
result.append(step_value)
current = current.add(seconds=interval_s)
return pd.Series(
result,
index=pd.DatetimeIndex(timestamps),
dtype=np.float64,
)
+23 -1
View File
@@ -62,11 +62,18 @@ MIGRATION_MAP: Dict[
"devices/batteries/0/initial_soc_percentage": None,
# - electric_vehicles
"devices/electric_vehicles/0/initial_soc_percentage": None,
# elecfee
# =======
# - ElecFeeFixed
# - ElecFeeImport
# elecprice
# =========
"elecprice/charges_kwh": None,
"elecprice/vat_rate": None,
# - ElecPriceAkkudoktor
# - ElecPriceEnergyCharts
# - ElecPriceFixed
"elecprice/elecpricefixed/time_windows": "elecprice/elecpricefixed/elecprice_marketprice_amt_kwh",
# - ElecPriceImport
"elecprice/provider_settings/ElecPriceImport/import_file_path": "elecprice/elecpriceimport/import_file_path",
"elecprice/provider_settings/ElecPriceImport/import_json": "elecprice/elecpriceimport/import_json",
@@ -75,7 +82,22 @@ MIGRATION_MAP: Dict[
# feedintariff
# ============
# - FeedInTariffFixed
"feedintariff/provider_settings/FeedInTariffFixed/feed_in_tariff_kwh": "feedintariff/feedintarifffixed/feed_in_tariff_kwh",
"feedintariff/feedintarifffixed/feed_in_tariff_kwh": (
"feedintariff/feedintarifffixed/feed_in_tariff_amt_kwh",
lambda v: {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": float(v)},
],
},
),
"feedintariff/provider_settings/FeedInTariffFixed/feed_in_tariff_kwh": (
"feedintariff/feedintarifffixed/feed_in_tariff_amt_kwh",
lambda v: {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": float(v)},
],
},
),
# - FeedInTariffImport
"feedintariff/provider_settings/FeedInTariffImport/import_file_path": "feedintariff/feedintariffimport/import_file_path",
"feedintariff/provider_settings/FeedInTariffImport/import_json": "feedintariff/feedintariffimport/import_json",
+1
View File
@@ -1272,6 +1272,7 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
Raises:
KeyError: If the specified key is not found in any of the DataRecords.
ValueError: If the fill_method, resample_method or boundary values are invalid
"""
# Validate fill method
if fill_method not in ("ffill", "bfill", "linear", "time", "none", None):
@@ -293,7 +293,7 @@ class GeneticOptimizationParameters(
# Assure predictions are uptodate
await cls.prediction.update_data()
try: # Try first - predition is also needed by the default PV forecast
try: # Try weather first - predition is also needed by the default PV forecast
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
@@ -311,6 +311,70 @@ class GeneticOptimizationParameters(
cls.config.weather.provider = "OpenMeteo"
# Retry
continue
# Try electricity fees next - predition is also needed by the default electricity price
# If no provider is set the fees default to 0 anyway
if cls.config.elecfee.provider:
try:
array = await cls.prediction.key_to_array(
key="elecfee_consumption_amt_kwh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
except Exception as e:
logger.info(
"No electricity fee data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.21,
},
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 19.0,
},
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
},
},
}
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="pvforecast_ac_power",
@@ -401,7 +465,7 @@ class GeneticOptimizationParameters(
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"duration": "1 day",
@@ -464,7 +528,15 @@ class GeneticOptimizationParameters(
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078,
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.078,
},
],
},
},
},
}
@@ -302,6 +302,70 @@ class Genetic0OptimizationParameters(
cls.config.weather.provider = "OpenMeteo"
# Retry
continue
# Try electricity fees next - predition is also needed by the default electricity price
# If no provider is set the fees default to 0 anyway
if cls.config.elecfee.provider:
try:
array = await cls.prediction.key_to_array(
key="elecfee_consumption_amt_kwh",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
except Exception as e:
logger.info(
"No electricity fee data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.21,
},
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 19.0,
},
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.0,
},
]
},
},
},
}
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="pvforecast_ac_power",
@@ -392,7 +456,7 @@ class Genetic0OptimizationParameters(
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"duration": "1 day",
@@ -455,7 +519,15 @@ class Genetic0OptimizationParameters(
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078,
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.078,
},
],
},
},
},
}
+65
View File
@@ -0,0 +1,65 @@
from typing import Optional
from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixedCommonSettings
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings
def elecfee_provider_ids() -> list[str]:
"""Valid elecfee provider ids."""
try:
prediction_eos = get_prediction()
except Exception:
# Prediction may not be initialized. Return static built-in provider ids.
return [
"ElecFeeFixed",
"ElecFeeImport",
]
return [
provider.provider_id()
for provider in prediction_eos.providers
if isinstance(provider, ElecFeeProvider)
]
class ElecFeeCommonSettings(SettingsBaseModel):
"""Electricity Price Prediction Configuration."""
provider: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Electricity fee provider id of provider to be used.",
"examples": ["ElecFeeFixed"],
},
)
elecfeefixed: ElecFeeFixedCommonSettings = Field(
default_factory=ElecFeeFixedCommonSettings,
json_schema_extra={"description": "Fixed electricity fees provider settings."},
)
elecfeeimport: ElecFeeImportCommonSettings = Field(
default_factory=ElecFeeImportCommonSettings,
json_schema_extra={"description": "Electricity fees import provider settings."},
)
@computed_field # type: ignore[prop-decorator]
@property
def providers(self) -> list[str]:
"""Available electricity fee provider ids."""
return elecfee_provider_ids()
# Validators
@field_validator("provider", mode="after")
@classmethod
def validate_provider(cls, value: Optional[str]) -> Optional[str]:
if value is None or value in elecfee_provider_ids():
return value
raise ValueError(
f"Provider '{value}' is not a valid electricity fees provider: {elecfee_provider_ids()}."
)
+128
View File
@@ -0,0 +1,128 @@
"""Abstract and base classes for electricity fee predictions.
Notes:
- Ensure appropriate API keys or configurations are set up if required by external data sources.
"""
from abc import abstractmethod
from typing import List, Optional
from pydantic import Field, computed_field
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
class ElecFeeDataRecord(PredictionRecord):
"""Represents a electricity price data record containing various price attributes at a specific datetime.
Attributes:
date_time (Optional[AwareDatetime]): The datetime of the record.
"""
elecfee_consumption_amt_wh: Optional[float] = Field(
None,
json_schema_extra={
"description": (
"Total fixed fee for consumed energy per Wh [amount/Wh]. "
"This is the accumulation of all fixed per-Wh fees payable on "
"consumed energy - such as network charge, concession fee, "
"and electricity charge - into a single amount."
),
},
)
elecfee_consumption_percent_amt: Optional[float] = Field(
None,
json_schema_extra={
"description": (
"Total fixed surcharge on consumed energy, given as a "
"percentage of the monetary amount already charged for that "
"energy [%]. This is the accumulation of all percentage-based "
"surcharges payable on top of the consumed-energy fee - such "
"as VAT - into a single percentage. This is a percentage of "
"the fee amount, not a per-Wh rate."
),
},
)
elecfee_feedin_amt_wh: Optional[float] = Field(
None,
json_schema_extra={
"description": (
"Total fixed deduction from feed-in energy per Wh [amount/Wh]. "
"This is the accumulation of all fixed per-Wh charges deducted from "
"feed-in energy - such as metering fees or grid-operator handling "
"charges - into a single amount. Applied after the percentage-based "
"deduction, i.e. it reduces the price by a flat amount per Wh "
"rather than by a share of the raw price."
),
},
)
elecfee_feedin_percent_amt: Optional[float] = Field(
None,
json_schema_extra={
"description": (
"Total percentage deducted from the raw feed-in price (spot price) "
"[%]. This is the accumulation of all percentage-based deductions "
"payable on the feed-in tariff - such as a marketing or balancing "
"fee retained by the aggregator - into a single percentage. It is "
"applied as `raw_price * (100 - percent) / 100`, i.e. it scales "
"down the raw price rather than adding a surcharge to it."
),
},
)
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def elecfee_consumption_amt_kwh(self) -> Optional[float]:
"""Electricity fee for consumed energy per kWh [amount/kWh].
This is the aggregation of fees that are to be paid by consumed energy per kWh - "
like network charge, concession fee, electricity charge."
Convenience attribute calculated from `elecfee_consumption_amt_wh`.
"""
if self.elecfee_consumption_amt_wh is None:
return None
return self.elecfee_consumption_amt_wh * 1000.0
@computed_field # type: ignore[prop-decorator]
@property
def elecfee_feedin_amt_kwh(self) -> Optional[float]:
"""Electricity fee for feed-in energy per kWh [amount/kWh].
This is the aggregation of fees that are to be paid by feed-in energy per kWh - "
like network charge, concession fee, electricity charge."
Convenience attribute calculated from `elecfee_feedin_amt_wh`.
"""
if self.elecfee_feedin_amt_wh is None:
return None
return self.elecfee_feedin_amt_wh * 1000.0
class ElecFeeProvider(PredictionProvider):
"""Abstract base class for electricity fee providers.
Electricity fee providers predict fees on consumed and feed-in electricity to be used by
electricity and feed-in price providers.
ElecFeeProvider is a thread-safe singleton, ensuring only one instance of this class is created.
"""
# overload
records: List[ElecFeeDataRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of ElecFeeDataRecord records"},
)
@classmethod
@abstractmethod
def provider_id(cls) -> str:
return "ElecFeeProvider"
def enabled(self) -> bool:
return self.provider_id() == self.config.elecfee.provider
@@ -0,0 +1,189 @@
"""Provides fixed fee electricity fee data."""
from typing import Optional
from loguru import logger
from pydantic import Field
from akkudoktoreos.config.configabc import (
SettingsBaseModel,
ValueTimeWindowSequence,
)
from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider
from akkudoktoreos.utils.datetimeutil import to_duration
class ElecFeeFixedCommonSettings(SettingsBaseModel):
"""Common settings for fixed electricity fees.
This model defines a fixed electricity fee schedule using a sequence
of time windows. Each window specifies a time interval and the electricity
fee applicable during that interval.
"""
consumption_amt_kwh: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": (
"Sequence of time windows defining the total fixed per-kWh electricty fee "
"charged for consumed energy, accumulating all applicable fixed "
"per-kWh charges (e.g. network charge, metering fee, concession "
"fee) into a single amount [amount/kWh]. If not provided, no fixed "
"per-kWh consumption fee is applied."
),
"examples": [
{
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00288},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0034},
],
}
],
},
)
consumption_percent_amt: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": (
"Sequence of time windows defining the total fixed electricity surcharge "
"applied as a percentage of the monetary amount already charged "
"for consumed energy, accumulating all applicable percentage-based "
"surcharges (e.g. VAT, electricity tax) into a single percentage "
"[%]. This is a percentage of the fee amount, not a per-kWh rate. "
"If not provided, no percentage-based consumption surcharge is "
"applied."
),
"examples": [
{
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 19.0},
],
}
],
},
)
feedin_amt_kwh: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": (
"Sequence of time windows defining the total deduction from feed-in energy "
"per Wh [amount/Wh]. This is the accumulation of all fixed per-Wh charges "
"deducted from feed-in energy - such as metering fees or grid-operator handling "
"charges - into a single amount. Applied after the percentage-based "
"deduction, i.e. it reduces the price by a flat amount per Wh "
"rather than by a share of the raw price. If not provided, no fixed per-kWh "
"feed-in fee is applied."
),
"examples": [
{
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00288},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0034},
],
}
],
},
)
feedin_percent_amt: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": (
"Sequence of time windows defining the total percentage deducted from the raw "
"feed-in price (spot price) [%]. This is the accumulation of all percentage-based "
"deductions payable on the feed-in tariff - such as a marketing or balancing "
"fee retained by the aggregator - into a single percentage. It is "
"applied as `raw_price * (100 - percent) / 100`, i.e. it scales "
"down the raw price rather than adding a surcharge to it. If not provided, no "
"percentage-based feed-in deduction is applied."
),
"examples": [
{
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 19.0},
],
}
],
},
)
class ElecFeeFixed(ElecFeeProvider):
"""Fixed fee electricity fee data.
ElecFeeFixed is a singleton-based class that retrieves electricity fee data
from a fixed schedule defined by time windows.
The provider generates hourly electricity fees based on the configured time windows.
For each hour in the forecast period, it determines which time window applies and
assigns the corresponding fee.
Attributes:
time_windows: Sequence of time windows with associated electricity fees.
"""
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the ElecFeeFixed provider."""
return "ElecFeeFixed"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update electricity fee data from fixed schedule.
Generates electricity fees based on the configured time windows
at the optimization interval granularity. The fee sequence starts
synchronized to the wall clock at the next full interval boundary.
Args:
force_update: If True, forces update even if data exists.
Raises:
ValueError: If no time windows are configured.
"""
elecfee_spec: dict[str, ValueTimeWindowSequence] = {
"elecfee_consumption_amt_wh": self.config.elecfee.elecfeefixed.consumption_amt_kwh,
"elecfee_consumption_percent_amt": self.config.elecfee.elecfeefixed.consumption_percent_amt,
"elecfee_feedin_amt_wh": self.config.elecfee.elecfeefixed.feedin_amt_kwh,
"elecfee_feedin_percent_amt": self.config.elecfee.elecfeefixed.feedin_percent_amt,
}
for prediction_key, time_window_seq in elecfee_spec.items():
if time_window_seq is None or not time_window_seq.windows:
warning_msg = f"No time windows configured for `{prediction_key}`, defaulting to 0."
logger.warning(warning_msg)
await self.update_value(self.ems_start_datetime, prediction_key, 0.0)
continue
start_datetime = self.ems_start_datetime
interval_seconds = 900 # Usual smallest time interval (15 min) used in electricty fees
total_hours = self.config.prediction.hours
interval = to_duration(interval_seconds)
end_datetime = start_datetime.add(hours=total_hours)
logger.debug(
f"Generating `{prediction_key}` for {total_hours} hours "
f"starting at {start_datetime}"
)
# Build the full fee array in one call — kWh values aligned to the
# optimization grid. to_series mirrors the key_to_series signature so
# the grid is constructed identically to how prediction data is read.
fees = time_window_seq.to_series(
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
dropna=True,
boundary="context",
align_to_interval=True,
)
if prediction_key.endswith("_wh"):
# Convert kWh → Wh
fees = fees / 1000.0
await self.key_from_series(prediction_key, fees)
logger.debug(f"Successfully generated {len(fees)} `{prediction_key}` entries")
@@ -0,0 +1,77 @@
"""Retrieves elecfee forecast data from an import file.
This module provides classes and mappings to manage elecfee data obtained from
an import file. The data is mapped to the `ElecFeeDataRecord` format, enabling consistent access
to forecasted and historical elecfee attributes.
"""
from pathlib import Path
from typing import Optional, Union
from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider
from akkudoktoreos.prediction.predictionabc import PredictionImportProvider
class ElecFeeImportCommonSettings(SettingsBaseModel):
"""Common settings for elecfee data import from file or JSON String."""
import_file_path: Optional[Union[str, Path]] = Field(
default=None,
json_schema_extra={
"description": "Path to the file to import elecfee data from.",
"examples": [None, "/path/to/prices.json"],
},
)
import_json: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "JSON string, dictionary of electricity fee forecast value lists.",
"examples": ['{"elecfee_consumption_amt_wh": [0.0003384, 0.0003318, 0.0003284]}'],
},
)
# Validators
@field_validator("import_file_path", mode="after")
@classmethod
def validate_import_file_path(cls, value: Optional[Union[str, Path]]) -> Optional[Path]:
if value is None:
return None
if isinstance(value, str):
value = Path(value)
"""Ensure file is available."""
value.resolve()
if not value.is_file():
raise ValueError(f"Import file path '{value}' is not a file.")
return value
class ElecFeeImport(ElecFeeProvider, PredictionImportProvider):
"""Fetch PV forecast data from import file or JSON string.
ElecFeeImport is a singleton-based class that retrieves elecfee forecast data
from a file or JSON string and maps it to `ElecFeeDataRecord` fields. It manages the forecast
over a range of hours into the future and retains historical data.
"""
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the ElecFeeImport provider."""
return "ElecFeeImport"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
# Both _sequence_lock and _record_lock are already held by the caller.
# Use internal sync methods only — never await public async counterparts.
if self.config.elecfee.elecfeeimport.import_file_path:
await self._import_from_file(
self.config.elecfee.elecfeeimport.import_file_path,
key_prefix="elecfee",
)
if self.config.elecfee.elecfeeimport.import_json:
await self._import_from_json(
self.config.elecfee.elecfeeimport.import_json,
key_prefix="elecfee",
)
+13 -16
View File
@@ -5,11 +5,15 @@ from pydantic import Field, computed_field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.elecpriceakkudoktor import (
ElecPriceAkkudoktorCommonSettings,
)
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyChartsCommonSettings,
)
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARDCommonSettings
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettings
@@ -23,6 +27,7 @@ def elecprice_provider_ids() -> list[str]:
"ElecPriceAkkudoktor",
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceSMARD",
"ElecPriceImport",
"ElecPriceTibber",
]
@@ -45,22 +50,9 @@ class ElecPriceCommonSettings(SettingsBaseModel):
},
)
charges_kwh: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": "Electricity price charges [amount/kWh]. Will be added to variable market price.",
"examples": [0.21],
},
)
vat_rate: Optional[float] = Field(
default=1.19,
ge=0,
json_schema_extra={
"description": "VAT rate factor applied to electricity price when charges are used.",
"examples": [1.19],
},
akkudoktor: ElecPriceAkkudoktorCommonSettings = Field(
default_factory=ElecPriceAkkudoktorCommonSettings,
json_schema_extra={"description": "Akkudoktor electricity price provider settings."},
)
elecpricefixed: ElecPriceFixedCommonSettings = Field(
@@ -78,6 +70,11 @@ class ElecPriceCommonSettings(SettingsBaseModel):
json_schema_extra={"description": "Energy Charts provider settings."},
)
smard: ElecPriceSMARDCommonSettings = Field(
default_factory=ElecPriceSMARDCommonSettings,
json_schema_extra={"description": "SMARD electricity price provider settings."},
)
tibber: ElecPriceTibberCommonSettings = Field(
default_factory=ElecPriceTibberCommonSettings,
json_schema_extra={"description": "Tibber electricity price provider settings."},
+58 -18
View File
@@ -7,9 +7,11 @@ Notes:
from abc import abstractmethod
from typing import List, Optional
import pandas as pd
from pydantic import Field, computed_field
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
from akkudoktoreos.prediction.predictionabc import PredictionRecord
from akkudoktoreos.prediction.priceabc import PricePredictionProviderBase
class ElecPriceDataRecord(PredictionRecord):
@@ -20,8 +22,18 @@ class ElecPriceDataRecord(PredictionRecord):
"""
elecprice_marketprice_raw_wh: Optional[float] = Field(
None,
json_schema_extra={
"description": "Raw electricity market price per Wh, always excluding fees [amount/Wh]"
},
)
elecprice_marketprice_wh: Optional[float] = Field(
None, json_schema_extra={"description": "Electricity market price per Wh [amount/Wh]"}
None,
json_schema_extra={
"description": "Electricity market price per Wh, including fees if configured [amount/Wh]"
},
)
# Computed fields
@@ -37,24 +49,10 @@ class ElecPriceDataRecord(PredictionRecord):
return self.elecprice_marketprice_wh * 1000.0
class ElecPriceProvider(PredictionProvider):
class ElecPriceProvider(PricePredictionProviderBase):
"""Abstract base class for electricity price providers.
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables:
electricity price_provider (str): Prediction provider for electricity price.
Attributes:
hours (int, optional): The number of hours into the future for which predictions are generated.
historic_hours (int, optional): The number of past hours for which historical data is retained.
latitude (float, optional): The latitude in degrees, must be within -90 to 90.
longitude (float, optional): The longitude in degrees, must be within -180 to 180.
start_datetime (datetime, optional): The starting datetime for predictions, defaults to the current datetime if unspecified.
end_datetime (datetime, computed): The datetime representing the end of the prediction range,
calculated based on `start_datetime` and `hours`.
keep_datetime (datetime, computed): The earliest datetime for retaining historical data, calculated
based on `start_datetime` and `historic_hours`.
ElecPriceProvider is a thread-safe singleton, ensuring only one instance of this class is created.
"""
# overload
@@ -70,3 +68,45 @@ class ElecPriceProvider(PredictionProvider):
def enabled(self) -> bool:
return self.provider_id() == self.config.elecprice.provider
# --- PricePredictionProviderBase hooks -------------------------------
#
# Concrete for every electricity-price data source: the raw/gross record
# keys, the consumption-fee keys, and the fee formula itself don't vary
# by provider (ElecPriceEnergyCharts, ElecPriceAkkudoktor, ...), only by
# "electricity price" vs. "feed-in tariff" - so, unlike `provider_id`,
# these are NOT left abstract for concrete providers to fill in.
@property
def _raw_key(self) -> str:
"""Record key holding the fee-free raw series."""
return "elecprice_marketprice_raw_wh"
@property
def _gross_key(self) -> str:
"""Record key to write the fee-inclusive series to."""
return "elecprice_marketprice_wh"
@property
def _fee_keys(self) -> list[str]:
"""Prediction keys to fetch for fee computation."""
return ["elecfee_consumption_amt_wh", "elecfee_consumption_percent_amt"]
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
"""Add the per-Wh consumption fee, then apply the percent surcharge (e.g. VAT).
gross = (raw + elecfee_consumption_amt_wh) * (100 + elecfee_consumption_percent_amt) / 100
Args:
raw_amt_wh: Raw electricity market price (amount/Wh), fee-free.
df_fee: Fee dataframe aligned to `raw_amt_wh`'s index, with columns
matching `_fee_keys`.
Returns:
pd.Series: Gross electricity price (amount/Wh), same index as `raw_amt_wh`.
"""
return (
(raw_amt_wh + df_fee["elecfee_consumption_amt_wh"])
* (100.0 + df_fee["elecfee_consumption_percent_amt"])
/ 100.0
)
@@ -8,17 +8,20 @@ format, enabling consistent access to forecasted and historical electricity pric
from typing import Any, List, Optional, Union
import numpy as np
import pandas as pd
import requests
from loguru import logger
from pydantic import ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
class ElecPriceAkkudoktorCommonSettings(SettingsBaseModel):
"""Common configuration settings for Akkodoktor electricity pricing."""
class AkkudoktorElecPriceMeta(PydanticBaseModel):
@@ -64,6 +67,8 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
_update_data(): Processes and updates forecast data from Akkudoktor in ElecPriceDataRecord format.
"""
highest_orig_datetime: Optional[DateTime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Akkudoktor provider."""
@@ -116,28 +121,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return akkudoktor_data
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
async def _update_data(
self, force_update: Optional[bool] = False
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the ElecPriceDataRecord format.
Retrieves data from Akkudoktor, maps each Akkudoktor field to the corresponding
@@ -146,64 +130,81 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
"""
# Get Akkudoktor electricity price data
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
# 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
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
prices_wh = pd.Series(dtype=float) # Initialize an empty series
for value in akkudoktor_data.values:
orig_datetime = to_datetime(value.start, in_timezone=self.config.general.timezone)
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime
price_wh = value.marketpriceEurocentPerKWh / (100 * 1000) + charges_wh
price_wh = value.marketpriceEurocentPerKWh / (100 * 1000)
# Collect all values into the Pandas Series
series_data.at[orig_datetime] = price_wh
prices_wh.at[orig_datetime] = price_wh
# Update values using key_from_series
await self.key_from_series("elecprice_marketprice_wh", series_data)
# Generate history array for prediction
history = await self.key_to_array(
key="elecprice_marketprice_wh", end_datetime=highest_orig_datetime, fill_method="linear"
)
amount_datasets = len(self.records)
if not highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
self.highest_orig_datetime = highest_orig_datetime
# 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
# Every cycle fetches fresh data (no skip-fetch branch here, unlike
# ElecPriceEnergyCharts), so the gross-series recompute always needs to
# cover at least the freshly fetched range, in addition to the
# forward-looking window from ems_start_datetime.
gross_start_datetime = (
min(self.ems_start_datetime, prices_wh.index.min())
if not prices_wh.empty
else self.ems_start_datetime
)
# Always raw here - fees are applied once, later, over the complete
# raw+predicted series in _store_gross_series().
await self.key_from_series("elecprice_marketprice_raw_wh", prices_wh)
# Raw history only, so ETS/median always trains on the true
# wholesale-price signal, never on fee-inclusive values.
history = await self.key_to_array(
key="elecprice_marketprice_raw_wh",
end_datetime=highest_orig_datetime,
fill_method="linear",
)
# 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
needed_hours = int(
self.config.prediction.hours
- ((highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
)
if needed_hours <= 0:
# This might keep data longer than
# self.ems_start_datetime + self.config.prediction.hours in the records
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.ems_start_datetime}"
) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records
f"No prediction needed. needed_hours={needed_hours}, "
f"hours={self.config.prediction.hours}, "
f"highest_orig_datetime {highest_orig_datetime}, "
f"start_datetime {self.ems_start_datetime}"
)
# Fee schedule may have changed since the last run even without new
# market data; recompute gross only for the window that was
# actually touched (or is still forward-looking) this cycle.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=self.highest_orig_datetime + to_duration("1 second"),
)
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
prediction = self._predict(history, needed_hours)
# write predictions into the records, update if exist.
prediction_series = pd.Series(
@@ -213,46 +214,12 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
for i in range(len(prediction))
],
)
await self.key_from_series("elecprice_marketprice_wh", prediction_series)
await self.key_from_series("elecprice_marketprice_raw_wh", prediction_series)
# history2 = await self.key_to_array(key="elecprice_marketprice_wh", fill_method="linear") + 0.0002
# return history, history2, prediction # for debug main
"""
def visualize_predictions(
history: np.ndarray[Any, Any],
history2: np.ndarray[Any, Any],
predictions: np.ndarray[Any, Any],
) -> None:
import matplotlib.pyplot as plt
plt.figure(figsize=(28, 14))
plt.plot(range(len(history)), history, label="History", color="green")
plt.plot(range(len(history2)), history2, label="History_new", color="blue")
plt.plot(
range(len(history), len(history) + len(predictions)),
predictions,
label="Predictions",
color="red",
)
plt.title("Predictions ets")
plt.xlabel("Time")
plt.ylabel("Price")
plt.legend()
plt.savefig("predictions_vs_true.png")
plt.close()
def main() -> None:
# Initialize ElecPriceAkkudoktor with required parameters
elec_price_akkudoktor = ElecPriceAkkudoktor()
history, history2, predictions = elec_price_akkudoktor._update_data()
visualize_predictions(history, history2, predictions)
# print(history, history2, predictions)
if __name__ == "__main__":
main()
"""
# Bounded to [gross_start_datetime, end of the freshly predicted tail) -
# covers exactly what was fetched and/or predicted this cycle, not the
# entire (potentially multi-year) retained history.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=prediction_series.index.max() + to_duration("1 second"),
)
@@ -6,22 +6,20 @@ humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPric
format, enabling consistent access to forecasted and historical electricity price attributes.
"""
from datetime import datetime
import time
from enum import StrEnum
from typing import Any, List, Optional, Union
import numpy as np
import pandas as pd
import requests
from loguru import logger
from pydantic import Field, ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
class EnergyChartsBiddingZones(StrEnum):
@@ -89,7 +87,11 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
"""
highest_orig_datetime: Optional[datetime] = None
highest_orig_datetime: Optional[DateTime] = None
def historic_hours_min(self) -> int:
"""Keep enough history for weekly seasonal price extrapolation."""
return 24 * 35
@classmethod
def provider_id(cls) -> str:
@@ -112,6 +114,15 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
raise ValueError(error_msg)
return energy_charts_data
def _bidding_zone(self) -> str:
settings = self.config.elecprice.energycharts
if settings is None:
return EnergyChartsBiddingZones.DE_LU.value
bidding_zone = settings.bidding_zone
if isinstance(bidding_zone, EnergyChartsBiddingZones):
return bidding_zone.value
return str(bidding_zone)
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch electricity price forecast data from Energy-Charts API.
@@ -134,25 +145,43 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
bidding_zone = str(self.config.elecprice.energycharts.bidding_zone)
url = f"{source}/price?bzn={bidding_zone}&start={start_date}&end={last_date}"
response = requests.get(url, timeout=30)
logger.debug(f"Response from {url}: {response}")
response.raise_for_status() # Raise an error for bad responses
energy_charts_data = self._validate_data(response.content)
# We are working on fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}"
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
# Retry transient network problems (timeouts / connection resets) a few
# times with a short backoff. Uses a (connect, read) timeout tuple so a
# slow-to-respond API does not block forever but also is not aborted
# after a too-short single read window.
max_attempts = 3
last_exc: Optional[Exception] = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(url, timeout=(5, 60))
logger.debug(f"Response from {url}: {response}")
response.raise_for_status()
energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return energy_charts_data
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
last_exc = exc
logger.warning(
"Energy-Charts request attempt {}/{} failed: {}",
attempt,
max_attempts,
exc,
)
if attempt < max_attempts:
time.sleep(2 * attempt)
# All attempts exhausted - re-raise the last transient error so the
# caller (_update_data) can decide whether to fall back to history.
raise last_exc # type: ignore[misc]
async def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
# 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
prices_wh = pd.Series(dtype=float) # Initialize an empty series
# Iterate over timestamps and prices together
for unix_sec, price_eur_per_mwh in zip(
@@ -164,40 +193,17 @@ 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
price_wh = price_eur_per_mwh / 1_000_000
# Store in series
series_data.at[orig_datetime] = price_wh
prices_wh.at[orig_datetime] = price_wh
return series_data
# Always raw here — fees are applied once, later, over the complete
# raw+predicted series in _store_gross_series().
return prices_wh
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
async def _update_data(
self, force_update: Optional[bool] = False
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the ElecPriceDataRecord format.
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
@@ -214,83 +220,158 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
# Determine if update is needed and how many days
past_days = 35
if self.highest_orig_datetime:
history_series = await self.key_to_raw_series(
key="elecprice_marketprice_wh", start_datetime=self.ems_start_datetime
)
# If history lower, then start_datetime
if history_series.index.min() <= self.ems_start_datetime:
past_days = 0
# Lower bound for the gross-series recompute below: defaults to "from
# now", i.e. only the still-relevant forward-looking window, unless a
# fresh fetch widens it to cover newly-arrived historic data too.
gross_start_datetime = self.ems_start_datetime
needs_update = end > self.highest_orig_datetime
# Set default start_datetime - try to take data from 5 weeks back for prediction
past_days = 35
start_datetime = self.ems_start_datetime - to_duration(f"{past_days} days")
# Determine if update is needed and what start date is really necessary
needs_update = False
if self.highest_orig_datetime:
raw_history = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
start_datetime=start_datetime,
end_datetime=gross_start_datetime,
)
if raw_history.empty:
# We need the default start date (35 days in past)
needs_update = True
else:
# A later update must not mistake the current forecast window for
# sufficient ETS history. Require the same amount of data that the
# weekly prediction branch in _predict needs; otherwise fetch 35
# days again and repair an already-truncated in-memory history.
resolution_seconds = self._resolution_seconds(raw_history)
slots_per_hour = 3600 // resolution_seconds
if len(raw_history) <= 2 * 168 * slots_per_hour:
# Not enough slots in history, default start date
needs_update = True
elif force_update:
# Use default start date in case of forced update
needs_update = True
elif end > self.highest_orig_datetime:
# We got enough history, but still not enough data to prediction end
start_datetime = gross_start_datetime
needs_update = True
else:
needs_update = True
if needs_update:
logger.info(
f"Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
"Update ElecPriceEnergyCharts is needed, last in history: {}, "
"force_update={}, start_datetime={}",
self.highest_orig_datetime,
bool(force_update),
start_datetime,
)
# Set start_date try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.ems_start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
)
# Get Energy-Charts electricity price data
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
) # type: ignore
# Parse and store data
series_data = self._parse_data(energy_charts_data)
self.highest_orig_datetime = series_data.index.max()
await self.key_from_series("elecprice_marketprice_wh", series_data)
# Get Energy-Charts electricity price data
try:
energy_charts_data = self._request_forecast(
start_date=to_datetime(start_datetime, as_string="YYYY-MM-DD"),
force_update=force_update,
) # type: ignore
# Parse and store data
series_data = await self._parse_data(energy_charts_data)
if series_data.empty:
raise ValueError("No Energy-Charts electricity price data available")
self.highest_orig_datetime = to_datetime(series_data.index.max())
await self.key_from_series("elecprice_marketprice_raw_wh", series_data)
# Newly fetched data widens the window that needs its gross
# (fee-inclusive) values recomputed.
gross_start_datetime = to_datetime(series_data.index.min())
except Exception as exc:
if self.highest_orig_datetime is None:
raise
logger.warning(
"Energy-Charts electricity price update failed ({}); keeping "
"existing history until {} and extrapolating the remaining "
"slots via ETS.",
exc,
self.highest_orig_datetime,
)
else:
logger.info(
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
"No Update ElecPriceEnergyCharts is needed, last in history: {}",
self.highest_orig_datetime,
)
# Generate history array for prediction
history = await self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
amount_datasets = len(self.records)
if not self.highest_orig_datetime: # mypy fix
if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# 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
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
raw_series = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
# Raw history only. Guaranteed fee-free regardless of which branch ran
# above, so ETS/median always trains on the true wholesale-price signal.
history = await self.key_to_array(
key="elecprice_marketprice_raw_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
if needed_hours <= 0:
# Signed gap: positive when existing raw data already reaches past
# ems_start_datetime (fewer slots left to predict); negative when the
# newest known data point (highest_orig_datetime) is older than
# ems_start_datetime, e.g. after a fetch outage - in that case we need
# extra slots to also backfill the gap up to ems_start_datetime, on top
# of the full prediction.hours horizon beyond it.
covered_slots = int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
# This might keep data longer than
# self.ems_start_datetime + self.config.prediction.hours in the records
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.ems_start_datetime}"
) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records
"No electricity price prediction needed. needed_slots={}, hours={}, "
"resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
)
# Fee schedule may have changed since the last run even without new
# market data; recompute gross only for the window that was
# actually touched (or is still forward-looking) this cycle.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
prediction = self._predict(history, needed_slots, slots_per_hour=slots_per_hour)
# write predictions into the records, update if exist.
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
await self.key_from_series("elecprice_marketprice_wh", prediction_series)
await self.key_from_series("elecprice_marketprice_raw_wh", prediction_series)
# Bounded to [gross_start_datetime, end of the freshly predicted tail) -
# covers exactly what was fetched and/or predicted this cycle, not the
# entire (potentially multi-year) retained history.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=to_datetime(prediction_series.index.max()) + to_duration("1 second"),
)
+41 -19
View File
@@ -10,7 +10,7 @@ from akkudoktoreos.config.configabc import (
ValueTimeWindowSequence,
)
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_duration
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
class ElecPriceFixedCommonSettings(SettingsBaseModel):
@@ -21,7 +21,7 @@ class ElecPriceFixedCommonSettings(SettingsBaseModel):
price applicable during that interval.
"""
time_windows: ValueTimeWindowSequence = Field(
elecprice_marketprice_amt_kwh: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": (
@@ -54,6 +54,8 @@ class ElecPriceFixed(ElecPriceProvider):
time_windows: Sequence of time windows with associated electricity prices.
"""
highest_orig_datetime: Optional[DateTime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the ElecPriceFixed provider."""
@@ -72,40 +74,60 @@ class ElecPriceFixed(ElecPriceProvider):
Raises:
ValueError: If no time windows are configured.
"""
time_windows_seq = self.config.elecprice.elecpricefixed.time_windows
if time_windows_seq is None or not time_windows_seq.windows:
error_msg = "No time windows configured for fixed electricity price"
logger.error(error_msg)
raise ValueError(error_msg)
prediction_key = "elecprice_marketprice_wh"
raw_prediction_key = "elecprice_marketprice_raw_wh"
time_windows_seq: ValueTimeWindowSequence = (
self.config.elecprice.elecpricefixed.elecprice_marketprice_amt_kwh
)
start_datetime = self.ems_start_datetime
interval_seconds = 900 # Usual smallest time interval (15 min) used in electricty prices
total_hours = self.config.prediction.hours
interval = to_duration(interval_seconds)
end_datetime = start_datetime.add(hours=total_hours)
if time_windows_seq is None or not time_windows_seq.windows:
warning_msg = f"No time windows configured for `{raw_prediction_key}`, defaulting to 0."
logger.warning(warning_msg)
# Store two values to have a default interval to be used by _apply_fees
end_datetime = start_datetime + interval
await self.update_value(start_datetime, raw_prediction_key, 0.0)
await self.update_value(end_datetime, raw_prediction_key, 0.0)
self.highest_orig_datetime = end_datetime
await self._store_gross_series(
start_datetime=start_datetime,
end_datetime=end_datetime + to_duration("1 second"),
)
return
logger.debug(
f"Generating fixed electricity prices for {total_hours} hours "
f"starting at {start_datetime}"
f"Generating {raw_prediction_key} for {total_hours} hours starting at {start_datetime}"
)
# Build the full price array in one call — kWh values aligned to the
# optimization grid. to_array mirrors the key_to_array signature so
# optimization grid. to_series mirrors the key_to_series signature so
# the grid is constructed identically to how prediction data is read.
prices_kwh = time_windows_seq.to_array(
prices_kwh = time_windows_seq.to_series(
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
dropna=True,
dropna=False,
boundary="context",
align_to_interval=True,
)
# Convert kWh → Wh and store one entry per interval step.
for idx, price_kwh in enumerate(prices_kwh):
current_dt = start_datetime.add(seconds=idx * interval_seconds)
await self.update_value(current_dt, "elecprice_marketprice_wh", price_kwh / 1000.0)
# Convert kWh → Wh
prices_wh = prices_kwh / 1000.0
logger.debug(f"Successfully generated {len(prices_kwh)} fixed electricity price entries")
await self.key_from_series(raw_prediction_key, prices_wh)
self.highest_orig_datetime = prices_wh.index.max()
# Bounded to exactly the window just generated - covers the whole
# forecast horizon in one shot, since ElecPriceFixed has no
# fetch/predict split to worry about like the other providers.
await self._store_gross_series(
start_datetime=prices_wh.index.min(),
end_datetime=prices_wh.index.max() + to_duration("1 second"),
)
logger.debug(f"Successfully generated {len(prices_wh)} `{prediction_key}` entries")
@@ -1,9 +1,8 @@
"""Retrieves elecprice forecast data from an import file.
This module provides classes and mappings to manage elecprice data obtained from
an import file, including support for various elecprice attributes such as temperature,
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
format, enabling consistent access to forecasted and historical elecprice attributes.
an import file. The data is mapped to the `ElecPriceDataRecord` format, enabling consistent access
to forecasted and historical elecprice attributes.
"""
from pathlib import Path
@@ -0,0 +1,188 @@
"""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,
)
@@ -7,7 +7,6 @@ import pandas as pd
import requests
from loguru import logger
from pydantic import Field, ValidationError
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
@@ -322,40 +321,6 @@ class ElecPriceTibber(ElecPriceProvider):
series = series.groupby(level=0).mean().sort_index()
return series.dropna()
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the native slot size in seconds from the series timestamps.
Uses the median of the timestamp differences so that a single outlier gap does
not distort the result. Falls back to hourly (3600 s) when fewer than two
timestamps are available.
"""
if len(series) < 2:
return 3600
deltas = pd.DatetimeIndex(series.index).to_series().diff().dropna()
if deltas.empty:
return 3600
resolution = int(round(deltas.dt.total_seconds().median()))
return resolution if resolution > 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
capped_data = data.clip(min=lower_bound, max=upper_bound)
return capped_data
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
def _predict_missing_prices(
self, history: np.ndarray, slots: int, slots_per_hour: int
) -> np.ndarray:
@@ -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
def feedintariff_provider_ids() -> list[str]:
@@ -27,6 +28,7 @@ def feedintariff_provider_ids() -> list[str]:
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTariffImport",
"FeedInTariffSMARD",
"FeedInTariffTibber",
]
@@ -68,6 +70,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
json_schema_extra={"description": "EnergyCharts feed in tariff provider settings."},
)
smard: FeedInTariffSMARDCommonSettings = Field(
default_factory=FeedInTariffSMARDCommonSettings,
json_schema_extra={"description": "SMARD feed in tariff provider settings."},
)
@computed_field # type: ignore[prop-decorator]
@property
def providers(self) -> list[str]:
@@ -7,9 +7,11 @@ Notes:
from abc import abstractmethod
from typing import List, Optional
import pandas as pd
from pydantic import Field, computed_field
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
from akkudoktoreos.prediction.predictionabc import PredictionRecord
from akkudoktoreos.prediction.priceabc import PricePredictionProviderBase
class FeedInTariffDataRecord(PredictionRecord):
@@ -20,8 +22,18 @@ class FeedInTariffDataRecord(PredictionRecord):
"""
feed_in_tariff_raw_wh: Optional[float] = Field(
None,
json_schema_extra={
"description": "Raw feed in tariff per Wh, always excluding fees [amount/Wh]"
},
)
feed_in_tariff_wh: Optional[float] = Field(
None, json_schema_extra={"description": "Feed in tariff per Wh [amount/Wh]"}
None,
json_schema_extra={
"description": "Feed in tariff per Wh, including fees if configured [amount/Wh]"
},
)
# Computed fields
@@ -37,13 +49,13 @@ class FeedInTariffDataRecord(PredictionRecord):
return self.feed_in_tariff_wh * 1000.0
class FeedInTariffProvider(PredictionProvider):
class FeedInTariffProvider(PricePredictionProviderBase):
"""Abstract base class for feed in tariff providers.
FeedInTariffProvider is a thread-safe singleton, ensuring only one instance of this class is created.
Configuration variables:
feed in tariff_provider (str): Prediction provider for feed in tarif.
feedintariff.provider (str): Prediction provider for feed in tarif.
"""
# overload
@@ -59,3 +71,44 @@ class FeedInTariffProvider(PredictionProvider):
def enabled(self) -> bool:
return self.provider_id() == self.config.feedintariff.provider
# --- PricePredictionProviderBase hooks -------------------------------
#
# Concrete for every feed-in tariff data source: the raw/gross record
# keys, the feed-in-fee keys, and the fee formula itself don't vary by
# provider, only by "electricity price" vs. "feed-in tariff" - so, unlike
# `provider_id`, these are NOT left abstract for concrete providers to
# fill in.
@property
def _raw_key(self) -> str:
"""Record key holding the fee-free raw series."""
return "feed_in_tariff_raw_wh"
@property
def _gross_key(self) -> str:
"""Record key to write the fee-inclusive series to."""
return "feed_in_tariff_wh"
@property
def _fee_keys(self) -> list[str]:
"""Prediction keys to fetch for fee computation."""
return ["elecfee_feedin_amt_wh", "elecfee_feedin_percent_amt"]
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
"""Apply the percent surcharge (e.g. VAT), then subtract the per-Wh feed-in fee.
gross = raw * (100 - elecfee_feedin_percent_amt) / 100 - elecfee_feedin_amt_wh
Args:
raw_amt_wh: Raw feed-in tariff (amount/Wh), fee-free.
df_fee: Fee dataframe aligned to `raw_amt_wh`'s index, with columns
matching `_fee_keys`.
Returns:
pd.Series: Gross feed-in tariff (amount/Wh), same index as `raw_amt_wh`.
"""
return (
raw_amt_wh * (100.0 - df_fee["elecfee_feedin_percent_amt"]) / 100.0
- df_fee["elecfee_feedin_amt_wh"]
)
@@ -95,21 +95,6 @@ class FeedInTariffAkkudoktor(FeedInTariffProvider):
series.at[timestamp] = value.marketprice / 1_000_000
return series
def _predict_prices(self, history: np.ndarray, hours: int) -> np.ndarray:
"""Extend published prices to the configured prediction horizon."""
predictor = ElecPriceAkkudoktor()
if len(history) > 800:
return predictor._predict_ets(history, seasonal_periods=168, hours=hours)
if len(history) > 168:
return predictor._predict_ets(history, seasonal_periods=24, hours=hours)
if len(history) > 0:
logger.warning(
"Using median fallback for Akkudoktor feed-in tariff with only {} values.",
len(history),
)
return predictor._predict_median(history, hours=hours)
raise ValueError("No Akkudoktor feed-in tariff data available")
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update raw prices and extrapolate any missing horizon values."""
if not self.ems_start_datetime:
@@ -150,7 +135,8 @@ class FeedInTariffAkkudoktor(FeedInTariffProvider):
if needed_hours <= 0:
return
prediction = self._predict_prices(history, needed_hours)
prediction = self._predict(history, needed_hours)
prediction_series = pd.Series(
data=prediction,
index=[
@@ -4,7 +4,6 @@ import time
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import requests
from loguru import logger
@@ -40,8 +39,9 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
"""Fetch Energy-Charts market prices as feed-in tariff data.
This provider stores the raw Energy-Charts day-ahead market price as
``feed_in_tariff_wh``. Unlike ``ElecPriceEnergyCharts`` it intentionally
does not add electricity import charges or VAT.
``feed_in_tariff_raw_wh``, and derives ``feed_in_tariff_wh`` from it by
applying any configured feed-in fees (unconditionally; a fee-free result
requires leaving the ElecFee provider unconfigured).
"""
highest_orig_datetime: Optional[datetime] = None
@@ -66,7 +66,17 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
"""Fetch market price forecast data from Energy-Charts."""
"""Fetch electricity price forecast data from Energy-Charts API.
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
date range. The response data is parsed and returned as JSON for further processing.
Returns:
dict: The parsed JSON response from Energy-Charts API containing forecast data.
Raises:
ValueError: If the API response does not include expected `electricity price` data.
"""
source = "https://api.energy-charts.info"
if start_date is None:
start_date = to_datetime(
@@ -105,58 +115,41 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
raise last_exc # type: ignore[misc]
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
series_data = pd.Series(dtype=float)
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
# Initialize
highest_orig_datetime = None # newest datetime from the api after that we want to update.
prices_wh = pd.Series(dtype=float) # Initialize an empty series
# Iterate over timestamps and prices together
for unix_sec, price_eur_per_mwh in zip(
energy_charts_data.unix_seconds, energy_charts_data.price, strict=False
energy_charts_data.unix_seconds, energy_charts_data.price
):
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the current native market interval from recent timestamps."""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
# Track the latest datetime
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
highest_orig_datetime = orig_datetime
def _predict_prices(self, history: np.ndarray, slots: int, slots_per_hour: int) -> np.ndarray:
energycharts = ElecPriceEnergyCharts()
if len(history) > 800 * slots_per_hour:
logger.info(
"Using weekly seasonal ETS forecast for Energy-Charts feed-in tariff "
"with {} historical values.",
len(history),
)
return energycharts._predict_ets(
history, seasonal_periods=168 * slots_per_hour, hours=slots
)
if len(history) > 168 * slots_per_hour:
logger.info(
"Using daily seasonal ETS forecast for Energy-Charts feed-in tariff "
"with {} historical values.",
len(history),
)
return energycharts._predict_ets(
history, seasonal_periods=24 * slots_per_hour, hours=slots
)
if len(history) > 0:
logger.warning(
"Using constant median fallback for Energy-Charts feed-in tariff "
"with only {} historical values.",
len(history),
)
return energycharts._predict_median(history, hours=slots)
logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available")
# Convert EUR/MWh to EUR/Wh
price_wh = price_eur_per_mwh / 1_000_000
# Store in series
prices_wh.at[orig_datetime] = price_wh
# Always raw here — fees are applied once, later, over the complete
# raw+predicted series in _store_gross_series().
return prices_wh
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update feed-in tariff forecast data from Energy-Charts."""
"""Update feed-in tariff forecast data from Energy-Charts.
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
`FeedInTariffDataRecord` and applies any necessary scaling.
The final mapped and processed data is inserted into the sequence as `FeedInTariffDataRecord`.
"""
# New prices are available every day at 14:00
now = pd.Timestamp.now(tz=self.config.general.timezone)
midnight = now.normalize()
@@ -166,65 +159,79 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
# Determine if update is needed and how many days
# Lower bound for the gross-series recompute at the end of this method:
# defaults to "from now", widened to the fetched window's start if a
# fetch actually happens below.
gross_start_datetime = self.ems_start_datetime
# Set default start_datetime - try to take data from 5 weeks back for prediction
past_days = 35
needs_history_refresh = False
start_datetime = self.ems_start_datetime - to_duration(f"{past_days} days")
# Determine if update is needed and what start date is really necessary
needs_update = False
if self.highest_orig_datetime:
raw_history = await self.key_to_raw_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
key="feed_in_tariff_raw_wh",
start_datetime=start_datetime,
end_datetime=gross_start_datetime,
)
# A later update must not mistake the current forecast window for
# sufficient ETS history. Require the same amount of data that the
# weekly prediction branch below needs; otherwise fetch 35 days
# again and repair an already-truncated in-memory history.
if not raw_history.empty:
if raw_history.empty:
# We need the default start date (35 days in past)
needs_update = True
else:
# A later update must not mistake the current forecast window for
# sufficient ETS history. Require the same amount of data that the
# weekly prediction branch in _predict needs; otherwise fetch 35
# days again and repair an already-truncated in-memory history.
resolution_seconds = self._resolution_seconds(raw_history)
slots_per_hour = 3600 // resolution_seconds
needs_history_refresh = len(raw_history) <= 800 * slots_per_hour
else:
needs_history_refresh = True
if not needs_history_refresh and not force_update:
past_days = 0
needs_update = (
bool(force_update) or end > self.highest_orig_datetime or needs_history_refresh
)
if len(raw_history) <= 2 * 168 * slots_per_hour:
# Not enough slots in history, default start date
needs_update = True
elif force_update:
# Use default start date in case of forced update
needs_update = True
elif end > self.highest_orig_datetime:
# We got enough history, but still not enough data to prediction end
start_datetime = gross_start_datetime
needs_update = True
else:
needs_update = True
if needs_update:
logger.info(
"Update FeedInTariffEnergyCharts is needed, last in history: {}, "
"force_update={}, history_refresh={}",
"force_update={}, start_datetime={}",
self.highest_orig_datetime,
bool(force_update),
needs_history_refresh,
start_datetime,
)
# Set start_date try to take data from 5 weeks back for prediction
start_date = to_datetime(
self.ems_start_datetime - to_duration(f"{past_days} days"),
as_string="YYYY-MM-DD",
)
# Get Energy-Charts electricity price data
try:
energy_charts_data = self._request_forecast(
start_date=start_date, force_update=force_update
start_date=to_datetime(start_datetime, as_string="YYYY-MM-DD"),
force_update=force_update,
) # type: ignore
# Parse and store data
series_data = self._parse_data(energy_charts_data)
if series_data.empty:
raise ValueError("No Energy-Charts feed-in tariff data available")
self.highest_orig_datetime = series_data.index.max()
await self.key_from_series("feed_in_tariff_wh", series_data)
self.highest_orig_datetime = to_datetime(series_data.index.max())
await self.key_from_series("feed_in_tariff_raw_wh", series_data)
# Newly fetched data widens the window that needs its gross
# (fee-inclusive) values recomputed.
gross_start_datetime = to_datetime(series_data.index.min())
except Exception as exc:
if self.highest_orig_datetime is None:
# Cold start: no cached/historical data to fall back to, so a
# failed fetch is fatal.
raise
# Transient API outage with existing history available: do not
# abort the whole prediction update. Keep the existing history
# and let the ETS/median branch below extrapolate the remaining
# 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 "
"existing history until {} and extrapolating the remaining "
@@ -244,32 +251,36 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
raise ValueError(error_msg)
raw_series = await self.key_to_raw_series(
key="feed_in_tariff_wh",
key="feed_in_tariff_raw_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
# Raw history only. Guaranteed fee-free regardless of which branch ran
# above, so ETS/median always trains on the true wholesale-price signal.
history = await self.key_to_array(
key="feed_in_tariff_wh",
key="feed_in_tariff_raw_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
# 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
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
+ 1
)
# Signed gap: positive when existing raw data already reaches past
# ems_start_datetime (fewer slots left to predict); negative when the
# newest known data point (highest_orig_datetime) is older than
# ems_start_datetime, e.g. after a fetch outage - in that case we need
# extra slots to also backfill the gap up to ems_start_datetime, on top
# of the full prediction.hours horizon beyond it.
covered_slots = int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
# This might keep data longer than
# self.ems_start_datetime + self.config.prediction.hours in the records
logger.warning(
"No feed-in tariff prediction needed. needed_slots={}, hours={}, "
"resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
@@ -279,9 +290,18 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
self.highest_orig_datetime,
self.ems_start_datetime,
)
# Fee schedule may have changed since the last run even without new
# market data; recompute gross only for the window that was
# actually touched (or is still forward-looking) this cycle.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
return
prediction = self._predict_prices(history, needed_slots, slots_per_hour)
prediction = self._predict(history, needed_slots, slots_per_hour=slots_per_hour)
# write predictions into the records, update if exist.
prediction_series = pd.Series(
data=prediction,
index=[
@@ -289,4 +309,12 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
for i in range(len(prediction))
],
)
await self.key_from_series("feed_in_tariff_wh", prediction_series)
await self.key_from_series("feed_in_tariff_raw_wh", prediction_series)
# Bounded to [gross_start_datetime, end of the freshly predicted tail) -
# covers exactly what was fetched and/or predicted this cycle, not the
# entire (potentially multi-year) retained history.
await self._store_gross_series(
start_datetime=gross_start_datetime,
end_datetime=to_datetime(prediction_series.index.max()) + to_duration("1 second"),
)
@@ -5,20 +5,32 @@ from typing import Optional
from loguru import logger
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.config.configabc import (
SettingsBaseModel,
ValueTimeWindowSequence,
)
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
class FeedInTariffFixedCommonSettings(SettingsBaseModel):
"""Common settings for elecprice fixed price."""
feed_in_tariff_kwh: Optional[float] = Field(
default=None,
ge=0,
feed_in_tariff_amt_kwh: ValueTimeWindowSequence = Field(
default_factory=ValueTimeWindowSequence,
json_schema_extra={
"description": "Electricity price feed in tariff [amount/kWh].",
"examples": [0.078],
"description": (
"Sequence of time windows defining the electricity feed in tariff [amount/kWh]. "
"If not provided, no fixed feed in tariff is applied."
),
"examples": [
{
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.028},
{"start_time": "08:00", "duration": "16 hours", "value": 0.034},
],
}
],
},
)
@@ -29,23 +41,80 @@ class FeedInTariffFixed(FeedInTariffProvider):
FeedInTariffFixed is a singleton-based class that retrieves elecprice data.
"""
highest_orig_datetime: Optional[DateTime] = None
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the FeedInTariffFixed provider."""
return "FeedInTariffFixed"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
error_msg = (
"Feed in tariff missing in configuration: "
"feedintariff.feedintarifffixed.feed_in_tariff_kwh"
"""Update feed-in tariff data from fixed schedule.
Generates feed-in tariff based on the configured time windows
at the optimization interval granularity. The tariff sequence starts
synchronized to the wall clock at the next full interval boundary.
Args:
force_update: If True, forces update even if data exists.
Raises:
ValueError: If no time windows are configured.
"""
prediction_key = "feed_in_tariff_wh"
raw_prediction_key = "feed_in_tariff_raw_wh"
time_windows_seq: ValueTimeWindowSequence = (
self.config.feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh
)
try:
feed_in_tariff = self.config.feedintariff.feedintarifffixed.feed_in_tariff_kwh
except Exception:
logger.exception(error_msg)
raise ValueError(error_msg)
if feed_in_tariff is None:
logger.error(error_msg)
raise ValueError(error_msg)
feed_in_tariff_wh = feed_in_tariff / 1000
await self.update_value(to_datetime(), "feed_in_tariff_wh", feed_in_tariff_wh)
start_datetime = self.ems_start_datetime
interval_seconds = 900 # Usual smallest time interval (15 min) used in electricty prices
total_hours = self.config.prediction.hours
interval = to_duration(interval_seconds)
end_datetime = start_datetime.add(hours=total_hours)
if time_windows_seq is None or not time_windows_seq.windows:
warning_msg = f"No time windows configured for `{raw_prediction_key}`, defaulting to 0."
logger.warning(warning_msg)
# Store two values to have a default interval to be used by _apply_fees
end_datetime = start_datetime + interval
await self.update_value(start_datetime, raw_prediction_key, 0.0)
await self.update_value(end_datetime, raw_prediction_key, 0.0)
self.highest_orig_datetime = end_datetime
await self._store_gross_series(
start_datetime=start_datetime,
end_datetime=end_datetime + to_duration("1 second"),
)
return
logger.debug(
f"Generating {prediction_key} for {total_hours} hours starting at {start_datetime}"
)
# Build the full tariff array in one call — kWh values aligned to the
# optimization grid. to_series mirrors the key_to_series signature so
# the grid is constructed identically to how prediction data is read.
tariffs_kwh = time_windows_seq.to_series(
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
dropna=False,
boundary="context",
align_to_interval=True,
)
# Convert kWh → Wh
tariffs_wh = tariffs_kwh / 1000.0
await self.key_from_series(raw_prediction_key, tariffs_wh)
self.highest_orig_datetime = tariffs_wh.index.max()
# Bounded to exactly the window just generated - covers the whole
# forecast horizon in one shot, since FeedInTariffFixed has no
# fetch/predict split to worry about like the other providers.
await self._store_gross_series(
start_datetime=tariffs_wh.index.min(),
end_datetime=tariffs_wh.index.max() + to_duration("1 second"),
)
logger.debug(f"Successfully generated {len(tariffs_wh)} `{prediction_key}` entries")
@@ -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
)
+25 -1
View File
@@ -31,16 +31,20 @@ from typing import Optional, Union
from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImport
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.feedintariffdvhubonline import FeedInTariffDvhubOnline
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,16 +85,20 @@ class PredictionCommonSettings(SettingsBaseModel):
# Initialize forecast providers, all are singletons.
elecfee_fixed = ElecFeeFixed()
elecfee_import = ElecFeeImport()
elecprice_akkudoktor = ElecPriceAkkudoktor()
elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport()
elecprice_smard = ElecPriceSMARD()
elecprice_tibber = ElecPriceTibber()
feedintariff_akkudoktor = FeedInTariffAkkudoktor()
feedintariff_dvhubonline = FeedInTariffDvhubOnline()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport()
feedintariff_smard = FeedInTariffSMARD()
feedintariff_tibber = FeedInTariffTibber()
loadforecast_akkudoktor = LoadAkkudoktor()
loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted()
@@ -111,16 +119,20 @@ weather_import = WeatherImport()
def prediction_providers() -> list[
Union[
ElecFeeFixed,
ElecFeeImport,
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceFixed,
ElecPriceImport,
ElecPriceSMARD,
ElecPriceTibber,
FeedInTariffAkkudoktor,
FeedInTariffDvhubOnline,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffSMARD,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
@@ -144,16 +156,20 @@ def prediction_providers() -> list[
Factory for prediction container.
"""
global \
elecfee_fixed, \
elecfee_import, \
elecprice_akkudoktor, \
elecprice_energy_charts, \
elecprice_fixed, \
elecprice_import, \
elecprice_smard, \
elecprice_tibber, \
feedintariff_akkudoktor, \
feedintariff_dvhubonline, \
feedintariff_energy_charts, \
feedintariff_fixed, \
feedintariff_import, \
feedintariff_smard, \
feedintariff_tibber, \
loadforecast_akkudoktor, \
loadforecast_akkudoktor_adjusted, \
@@ -176,20 +192,24 @@ def prediction_providers() -> list[
# Inter provider dependencies:
# - pvforecast_pvlib depends on weather
return [
weather_brightsky, # weather maybe needed by the pvforcast, keep it before
weather_brightsky, # weather maybe needed by the pvforcast, keep before
weather_clearoutside,
weather_import,
weather_openmeteo,
elecfee_fixed, # elecfee maybe needed by elecprice and feedintariff, keep before
elecfee_import,
elecprice_akkudoktor,
elecprice_energy_charts,
elecprice_fixed,
elecprice_import,
elecprice_smard,
elecprice_tibber,
feedintariff_akkudoktor,
feedintariff_dvhubonline,
feedintariff_energy_charts,
feedintariff_fixed,
feedintariff_import,
feedintariff_smard,
feedintariff_tibber,
loadforecast_akkudoktor,
loadforecast_akkudoktor_adjusted,
@@ -210,16 +230,20 @@ class Prediction(PredictionContainer):
providers: list[
Union[
ElecFeeFixed,
ElecFeeImport,
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceFixed,
ElecPriceImport,
ElecPriceSMARD,
ElecPriceTibber,
FeedInTariffAkkudoktor,
FeedInTariffDvhubOnline,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffSMARD,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
+312
View File
@@ -0,0 +1,312 @@
"""Shared base for price-like predictions (electricity price, feed-in tariff)."""
from abc import abstractmethod
from typing import cast
import numpy as np
import pandas as pd
from loguru import logger
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.coreabc import PredictionMixin
from akkudoktoreos.prediction.predictionabc import PredictionProvider
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
class PricePredictionProviderBase(PredictionMixin, PredictionProvider):
"""Common forecasting + fee-application logic shared by price-like providers.
Subclasses must supply the raw/gross record keys, the fee keys to pull from
the prediction store, and the formula that combines raw price + fees.
"""
# --- identity hooks -------------------------------------------------
@property
@abstractmethod
def _raw_key(self) -> str:
"""Record key holding the fee-free raw series."""
@property
@abstractmethod
def _gross_key(self) -> str:
"""Record key to write the fee-inclusive series to."""
@property
@abstractmethod
def _fee_keys(self) -> list[str]:
"""Prediction keys to fetch for fee computation."""
@abstractmethod
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
"""Combine the raw series with the fetched fee dataframe."""
# --- forecasting helpers (verbatim, shared) --------------------------
def _resolution_seconds(self, series: pd.Series) -> int:
"""Infer the native slot size in seconds from the series timestamps.
Uses the median of the timestamp differences so that a single outlier gap does
not distort the result. Falls back to hourly (3600 s) when fewer than two
timestamps are available.
"""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
"""Clip extreme values in a price history to a range around the mean.
Values further than ``sigma`` standard deviations from the mean are clipped
to the corresponding bound. Used to keep single-point spikes (e.g. negative
price events or data glitches) from dominating seasonal decomposition or a
median fallback.
Args:
data: The raw price history to clip.
sigma: Number of standard deviations from the mean to allow before
clipping. Defaults to 2.
Returns:
A copy of ``data`` with outliers clipped to ``[mean - sigma * std,
mean + sigma * std]``.
"""
mean = data.mean()
std = data.std()
lower_bound = mean - sigma * std
upper_bound = mean + sigma * std
return data.clip(min=lower_bound, max=upper_bound)
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
"""Forecast future prices with additive Exponential Smoothing (ETS).
Fits a Holt-Winters model with an additive seasonal component to the
outlier-capped history and forecasts the requested number of hours ahead.
Args:
history: Historical price values, ordered oldest to newest.
seasonal_periods: Length of one seasonal cycle in the same unit as
``history`` (e.g. 24 for daily seasonality, 168 for weekly
seasonality on hourly data).
hours: Number of hours to forecast beyond the end of ``history``.
Returns:
An array of ``hours`` forecasted values.
Raises:
ValueError: If ``history`` has fewer than ``2 * seasonal_periods``
observations, which ETS needs to reliably estimate the seasonal
component.
"""
required_observations = 2 * seasonal_periods
if len(history) < required_observations:
raise ValueError(
f"Not enough history for ETS with seasonal_periods="
f"{seasonal_periods}: got {len(history)}, "
f"need at least {required_observations}"
)
clean_history = self._cap_outliers(history)
model = ExponentialSmoothing(
clean_history, seasonal="add", seasonal_periods=seasonal_periods
).fit()
return model.forecast(hours)
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
"""Forecast future prices as a constant equal to the historical median.
Fallback used when there isn't enough history for a seasonal ETS forecast.
Args:
history: Historical price values, ordered oldest to newest.
hours: Number of hours to forecast.
Returns:
An array of ``hours`` values, all equal to the median of the
outlier-capped history.
"""
clean_history = self._cap_outliers(history)
return np.full(hours, np.median(clean_history))
def _predict(self, history: np.ndarray, hours: int, slots_per_hour: int = 1) -> np.ndarray:
"""Forecast future prices, choosing seasonality by available history length.
Uses weekly-seasonal ETS if there's enough history for it, falls back to
daily-seasonal ETS with less, and to a constant median with too little
history for either.
Args:
history: Historical price values, ordered oldest to newest.
hours: Number of forecast steps to produce, at the resolution implied
by ``slots_per_hour`` (despite the name, not necessarily clock hours).
slots_per_hour: Number of samples per hour in ``history`` (e.g. 4 for
15-minute data). Scales the seasonal period so a "week" or "day"
still spans the right number of samples at sub-hourly resolution.
Defaults to 1 (hourly data).
Returns:
An array of ``hours`` forecasted values.
Raises:
ValueError: If ``history`` is empty.
"""
weekly_periods = 168 * slots_per_hour
daily_periods = 24 * slots_per_hour
history_length = len(history)
if history_length >= 2 * weekly_periods:
return self._predict_ets(history, seasonal_periods=weekly_periods, hours=hours)
elif history_length >= 2 * daily_periods:
return self._predict_ets(history, seasonal_periods=daily_periods, hours=hours)
elif history_length > 0:
logger.warning(
"Using median fallback to predict prices with only {} values.", len(history)
)
return self._predict_median(history, hours=hours)
logger.error("No data available for prediction")
raise ValueError("No data available")
# --- fee application (shared plumbing, subclass supplies formula) ----
async def _apply_fees(self, raw_price_amt_wh: pd.Series) -> pd.Series:
"""Apply fees to a raw price-like time series to produce the gross series.
The raw series is first normalized to a strictly uniform, sorted,
duplicate-free DatetimeIndex (resampling to a fixed 15-minute grid with
forward-fill if the input spacing isn't already uniform), since fees are
fetched from the prediction store over the resulting `[start, end)` window
at that resolution. The fee values are then combined with the raw series
via `_compute_gross`, which each subclass implements with its own formula
(e.g. add-then-percent for consumption, percent-then-subtract for feed-in).
Args:
raw_price_amt_wh: Raw price-like series (amount/Wh), indexed by a
timezone-aware DatetimeIndex. Excludes fees.
Returns:
pd.Series: Raw series with fees applied (amount/Wh), named the same
as `raw_price_amt_wh`. If the input index was uniform, the returned
index matches it; otherwise the returned index is the fixed
15-minute, forward-filled resampling of the input index.
Raises:
ValueError: If `raw_price_amt_wh` is empty, or has fewer than two
entries (so no interval can be derived).
TypeError: If `raw_price_amt_wh` is not indexed by a DatetimeIndex,
or if the derived interval is not a `pd.Timedelta`.
"""
if raw_price_amt_wh.empty:
raise ValueError("raw_price_amt_wh must not be empty.")
if len(raw_price_amt_wh.index) < 2:
raise ValueError(
"raw_price_amt_wh must have at least two entries to derive the interval."
)
# Normalize the index: sorted, unique timestamps only. Later duplicate
# timestamps win, since they're assumed to be the more recently written value.
index = raw_price_amt_wh.index.sort_values()
if not isinstance(index, pd.DatetimeIndex):
raise TypeError("raw_price_amt_wh must have a DatetimeIndex")
index = cast(pd.DatetimeIndex, index)
raw_price_amt_wh = raw_price_amt_wh.reindex(index)
raw_price_amt_wh = raw_price_amt_wh[~raw_price_amt_wh.index.duplicated(keep="last")]
index = cast(pd.DatetimeIndex, raw_price_amt_wh.index)
# Determine whether the (deduplicated) index has a single, uniform spacing.
diffs = index.to_series().diff().dropna().unique()
if len(diffs) != 1:
# Spacing is irregular (e.g. gaps or mixed resolutions): fall back to a
# fixed 15-minute grid spanning the same range, forward-filling gaps so
# every slot has a value before fees are fetched/applied.
diff0 = pd.Timedelta(minutes=15)
uniform_index = pd.date_range(start=index[0], end=index[-1], freq=diff0, tz=index.tz)
raw_price_amt_wh = (
raw_price_amt_wh.reindex(raw_price_amt_wh.index.union(uniform_index))
.sort_index()
.ffill()
.reindex(uniform_index)
)
index = uniform_index
logger.warning(
f"raw_price_amt_wh has non uniform spacing {diffs}; "
"resampled to fixed 15-minutes grid with forward filling gaps"
)
else:
# Already uniform: use the single observed spacing as-is.
diff = diffs[0]
if not isinstance(diff, pd.Timedelta):
raise TypeError("Expected a Timedelta")
diff0 = diff
# Window and resolution used to fetch fee data matching the raw series exactly.
# end_datetime is exclusive, so it's one interval past the last raw timestamp.
start_datetime = to_datetime(index[0].to_pydatetime())
end_datetime = to_datetime(index[-1].to_pydatetime() + diff0.to_pytimedelta())
interval = to_duration(f"{diff0.total_seconds()} seconds")
# Fetch the fee series/percentages this provider needs (subclass-specific keys).
keys = self._fee_keys
try:
df_fee = await self.prediction.keys_to_dataframe(
keys=keys,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method="linear",
resample_method="mean",
dropna=False,
boundary="context",
align_to_interval=True,
)
except KeyError:
# No fee provider enabled/configured for these keys: treat as zero
# fees rather than failing the whole price calculation.
df_fee = pd.DataFrame(0.0, index=raw_price_amt_wh.index, columns=keys)
# Guard against any boundary/resample mismatch between the fee dataframe
# and the raw price index (e.g. missing edge timestamps) by reindexing
# onto the raw index exactly and treating anything still missing as zero.
df_fee = df_fee.reindex(raw_price_amt_wh.index).fillna(0.0)
# Subclass-specific formula combining raw price and fees.
price_amt_wh = self._compute_gross(raw_price_amt_wh, df_fee)
price_amt_wh.name = raw_price_amt_wh.name
return price_amt_wh
async def _store_gross_series(
self,
start_datetime: DateTime | None = None,
end_datetime: DateTime | None = None,
) -> None:
"""Derive the fee-inclusive series from the fee-free (raw) series.
Recomputes the fee-inclusive series over `[start_datetime, end_datetime)`
only, so that historic and predicted values within that window get their
own correct time-window/weekday-specific fee amount. Deliberately bounded
rather than covering the entire retained history, which can span years -
callers are responsible for choosing bounds that cover every timestamp
written or possibly affected during the current update cycle.
Note: timestamps outside the given bounds keep whatever gross value was
computed for them in an earlier update cycle. If the fee schedule changes
in a way that should retroactively affect already-processed history, that
older range needs to be explicitly recomputed (e.g. via a forced refetch),
it will not happen automatically here.
Args:
start_datetime: Inclusive lower bound of the raw series to recompute.
end_datetime: Exclusive upper bound of the raw series to recompute.
"""
# Read back only the fee-free slice that needs recomputing...
full_raw_series = await self.key_to_raw_series(
key=self._raw_key, start_datetime=start_datetime, end_datetime=end_datetime
)
# ...apply fees to it...
full_series_with_fees = await self._apply_fees(full_raw_series)
# ...and persist the result under the gross key.
await self.key_from_series(self._gross_key, full_series_with_fees)
@@ -410,6 +410,86 @@ def make_config_update_list_form(available_values: list[str]) -> Callable[[str,
return ConfigUpdateListForm
def make_config_update_lazy_select_form(options_source: str) -> Callable[[str, str], Grid]:
"""Factory for a form that sets a value via a server-filtered select.
Unlike make_config_update_value_form, the <select> is never populated
with the full candidate list up front. A search Input drives an HTMX
GET against /eosdash/configuration/options/{options_source} which
returns a capped, filtered <select> fragment safe for lists with
thousands of entries (e.g. the pvlib CEC module/inverter database).
Args:
options_source: Registry key into uihints.LAZY_OPTIONS_SOURCES.
Returns:
A function (config_name: str, value: str) -> Grid
"""
def ConfigUpdateLazySelectForm(config_name: str, value: str) -> Grid:
config_id = config_name.lower().replace(".", "-")
select_id = f"{config_id}-lazy-select"
select_name = f"{config_id}_lazy_selected_value"
# value arrives JSON-encoded (e.g. '"SomeModule"') same as the
# other form factories receive it from ConfigCard.
try:
parsed = json.loads(value) if value else None
except (TypeError, ValueError):
parsed = value
current = "" if parsed is None else str(parsed)
return Grid(
DivRAligned(P("update value")),
DivHStacked(
ConfigButton(
"Set",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f"""js:{{
action: "update",
key: "{config_name}",
value: document.querySelector("#{select_id}").value
}}""",
hx_include=HTMX_INCLUDE,
),
Div(
Input(
placeholder="Type to search…",
id=f"{config_id}-lazy-search",
autocomplete="off",
hx_get=request_url_for(f"/eosdash/configuration/options/{options_source}"),
hx_trigger="load, keyup delay:300ms",
# hx_trigger="load, keyup",
hx_target=f"#{select_id}",
hx_swap="outerHTML",
hx_vals=f"""js:{{
search: this.value,
select_id: {json.dumps(select_id)},
name: {json.dumps(select_name)},
current: {json.dumps(current)}
}}""",
cls="border rounded px-3 py-2 mb-1 w-full",
),
Select(
Option(current, value=current, selected=True)
if current
else Option("Select a value...", value="", selected=True, disabled=True),
id=select_id,
name=select_name,
required=True,
cls="border rounded px-3 py-2 w-full",
),
cls="col-span-4",
),
),
id=f"{config_id}-update-lazy-select-form",
)
return ConfigUpdateLazySelectForm
def make_config_update_map_form(
available_keys: list[str] | None = None,
available_values: list[str] | None = None,
+174 -71
View File
@@ -4,6 +4,7 @@ from collections.abc import Sequence
from typing import Any, Dict, List, Optional, TypeVar, Union
import requests
from fasthtml.common import Select
from loguru import logger
from monsterui.franken import (
Card,
@@ -11,6 +12,7 @@ from monsterui.franken import (
Div,
Grid,
LabelCheckboxX,
Option,
)
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
@@ -24,10 +26,13 @@ from akkudoktoreos.server.dash.components import (
Input,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.eosstatus import eos_pvlib_cec_names, eos_server_address
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
from akkudoktoreos.server.dash.uihints import (
LAZY_OPTIONS_SOURCES,
UI_HINTS,
register_lazy_options_source,
resolve_form_factory,
)
@@ -258,73 +263,68 @@ def create_config_details(
dict[dict]: A dictionary of configuration details, each represented as a dictionary.
"""
config_details: dict[str, dict] = {}
inner_types: set[type[PydanticBaseModel]] = set()
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo],
parent_types: list[str],
visited: frozenset,
) -> None:
nonlocal values, values_prefix
regular_field = isinstance(subfield_info, FieldInfo)
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
nested_types = resolve_nested_types(subtype, [])
found_basic = False
for nested_type, nested_parent_types in nested_types:
if not isinstance(nested_type, type) or not issubclass(nested_type, PydanticBaseModel):
if found_basic:
continue
extra = get_field_extra_dict(subfield_info)
config: dict[str, Optional[Any]] = {}
config["name"] = ".".join(values_prefix + parent_types)
config["value"] = json.dumps(
get_nested_value(values, values_prefix + parent_types, "<unknown>")
)
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
config["description"] = get_description(subfield_info, extra)
config["deprecated"] = get_deprecated(subfield_info, extra)
config["scope"] = get_scope(extra)
if isinstance(subfield_info, ComputedFieldInfo):
config["read-only"] = "ro"
type_description = str(subfield_info.return_type)
else:
config["read-only"] = "rw"
type_description = str(subfield_info.annotation)
config["type"] = (
type_description.replace("typing.", "")
.replace("pathlib.", "")
.replace("NoneType", "None")
.replace("<class 'float'>", "float")
)
config_details[str(config["name"])] = config
found_basic = True
else:
if nested_type in visited:
# Genuine cycle along this path (self-referential model) — stop
# recursing here, but do NOT block sibling branches elsewhere.
continue
new_parent_types = parent_types + nested_parent_types
new_visited = visited | {nested_type}
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
extract_nested_models(
nested_field_info,
new_parent_types + [nested_field_name],
new_visited,
)
for field_name, field_info in list(model.model_fields.items()) + list(
model.model_computed_fields.items()
):
extract_nested_models(field_info, [field_name], frozenset())
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
) -> None:
"""Extract nested models from the given subfield information.
Args:
subfield_info (Union[ComputedFieldInfo, FieldInfo]): Field metadata from Pydantic.
parent_types (list[str]): A list of parent type names for hierarchical representation.
"""
nonlocal values, values_prefix
regular_field = isinstance(subfield_info, FieldInfo)
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
if subtype in inner_types:
return
nested_types = resolve_nested_types(subtype, [])
found_basic = False
for nested_type, nested_parent_types in nested_types:
if not isinstance(nested_type, type) or not issubclass(
nested_type, PydanticBaseModel
):
if found_basic:
continue
extra = get_field_extra_dict(subfield_info)
config: dict[str, Optional[Any]] = {}
config["name"] = ".".join(values_prefix + parent_types)
config["value"] = json.dumps(
get_nested_value(values, values_prefix + parent_types, "<unknown>")
)
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
config["description"] = get_description(subfield_info, extra)
config["deprecated"] = get_deprecated(subfield_info, extra)
config["scope"] = get_scope(extra)
if isinstance(subfield_info, ComputedFieldInfo):
config["read-only"] = "ro"
type_description = str(subfield_info.return_type)
else:
config["read-only"] = "rw"
type_description = str(subfield_info.annotation)
config["type"] = (
type_description.replace("typing.", "")
.replace("pathlib.", "")
.replace("NoneType", "None")
.replace("<class 'float'>", "float")
)
config_details[str(config["name"])] = config
found_basic = True
else:
new_parent_types = parent_types + nested_parent_types
inner_types.add(nested_type)
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
extract_nested_models(
nested_field_info,
new_parent_types + [nested_field_name],
)
extract_nested_models(field_info, [field_name])
return config_details
@@ -366,6 +366,93 @@ def config_matches_search(config: dict, search: str) -> bool:
)
# ---------------------
# Lazy Options Handling
# ---------------------
def _fetch_cec_names(kind: str) -> tuple[str, ...]:
"""Fetch CEC module or inverter names from the EOS server.
Cached per kind for the EOSdash process lifetime the CEC database
is static.
Args:
kind: "modules" or "inverters".
"""
global eos_server_address, eos_pvlib_cec_names
if kind in eos_pvlib_cec_names:
# Already cached
return eos_pvlib_cec_names[kind]
if eos_server_address is None:
logger.warning(
f"EOS server address not yet known: `{eos_server_address}`; cannot fetch CEC {kind}"
)
return ()
host, port = eos_server_address
# host = "127.0.0.1"
# port = 8503
server = f"http://{host}:{port}"
path = f"/v1/prediction/pvforecast/pvlib/{kind}"
try:
result = requests.get(f"{server}{path}", timeout=10)
result.raise_for_status()
data = result.json()
names = tuple(data) if isinstance(data, list) else tuple(data.keys())
eos_pvlib_cec_names[kind] = names
except requests.exceptions.RequestException as e:
logger.warning(f"Can not retrieve CEC {kind} from {server}: {e}")
return ()
logger.info(f"Fetched CEC {kind}")
return names
def _cec_modules_source() -> list[str]:
return list(_fetch_cec_names("modules"))
def _cec_inverters_source() -> list[str]:
return list(_fetch_cec_names("inverters"))
register_lazy_options_source("pvlib.modules", _cec_modules_source)
register_lazy_options_source("pvlib.inverters", _cec_inverters_source)
def config_options(
options_source: str, search: str, select_id: str, name: str, current: str
) -> Select:
"""Return a filtered, capped <select> fragment for a select_lazy field."""
source = LAZY_OPTIONS_SOURCES.get(options_source)
all_values = source() if source else []
search_l = (search or "").strip().lower()
matches = [v for v in all_values if search_l in v.lower()] if search_l else all_values
matches = matches[:50] # hard cap regardless of source size
if current and current not in matches:
# keep the current selection visible even if it doesn't match
# the active filter, so switching searches never silently loses it
matches = [current] + matches
return Select(
*[Option(v, value=v, selected=(v == current)) for v in matches],
id=select_id,
name=name,
required=True,
cls="border rounded px-3 py-2 w-full",
)
# -----------------------------------
# Configuration Visual Representation
# -----------------------------------
def Configuration(
eos_host: str,
eos_port: Union[str, int],
@@ -381,9 +468,12 @@ def Configuration(
Returns:
rows: Rows of configuration details.
"""
global config_visible
global config_visible, eos_server_address
dark = False
# Remember for usage
eos_server_address = (eos_host, int(eos_port))
if data and data.get("action", None):
if data.get("dark", None) == "true":
dark = True
@@ -526,11 +616,13 @@ def Configuration(
logger.debug(f"devices_measurement_keys {devices_measurement_keys}")
# build visual representation
sections: dict[str, list[Any]] = {}
sections: dict[str, dict[str, Any]] = {}
# Fill sections with config cards
for config_key in sorted(config_details.keys()):
config = config_details[config_key]
category = config["name"].split(".")[0]
sections.setdefault(category, {})
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
@@ -594,23 +686,34 @@ def Configuration(
else:
continue
sections.setdefault(category, []).append(card)
sections[category][config["name"]] = card
section_components = []
for category in sorted(sections.keys()):
cards = sections[category]
# Open if searching OR if last update was here
# Open if searching OR if last update touched this category — including
# updates on deeply nested sub-fields (e.g. pvforecast.planes.2.module_model)
# whose full dotted name never appears as a key in the outer config_details
# dict, since that dict only holds top-level model fields. ConfigItemsCard
# resolves per-item sub-fields internally via its own create_config_details
# call, so we must match against config_update_latest's own keys instead.
open_section = bool(search_value)
if not open_section:
open_section = any(
config_update_latest.get(c["name"], {}).get("open")
for c in config_details.values()
if c["name"].startswith(category)
info.get("open")
for key, info in config_update_latest.items()
if key == category or key.startswith(f"{category}.")
)
cards: list[Card] = []
for name, card in sections[category].items():
if name.rsplit(".", 1)[-1] == "provider":
# Make provider the first config item in category
cards.insert(0, card)
else:
cards.append(card)
section_components.append(ConfigSection(category, *cards, open=open_section))
return Div(
-5
View File
@@ -87,11 +87,6 @@ class IngressMiddleware(BaseHTTPMiddleware):
or request.headers.get("X-INGRESS-PATH", "")
)
# Debug logging - remove after testing
logger.debug(f"All headers: {dict(request.headers)}")
logger.debug(f"Ingress path: {ingress_path}")
logger.debug(f"Request path: {request.url.path}")
# Only set root_path if we have an ingress path
if ingress_path:
ROOT_PATH = ingress_path
@@ -11,3 +11,7 @@ eos_health: Optional[dict] = None
eos_solution: Optional[OptimizationSolution] = None
eos_plan: Optional[EnergyManagementPlan] = None
eos_config: Optional[SettingsEOS] = None
eos_server_address: Optional[tuple[str, int]] = None
# dictionary for `modules` and `inverters` (str) names (tuple[str]).
eos_pvlib_cec_names: dict[str, tuple[str]] = {}
+94 -4
View File
@@ -26,6 +26,7 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Optional
from akkudoktoreos.server.dash.components import (
make_config_update_lazy_select_form,
make_config_update_list_form,
make_config_update_map_form,
make_config_update_time_windows_windows_form,
@@ -40,6 +41,7 @@ UiFormType = Literal[
"text", # plain text input (default)
"select", # single-value dropdown
"select_list", # add/delete multi-value list
"select_lazy", # server-filtered select for large option sets
"map", # key/value pair editor
"time_windows", # time-window sequence editor
"items", # expandable list of sub-model cards
@@ -68,6 +70,12 @@ class UiHint:
option list (JSON-encoded ``list[str]``). Takes precedence
over ``options`` when both are set.
options_source:
``select_lazy`` only. Key into LAZY_OPTIONS_SOURCES identifying the
callable that lazily returns the full (potentially huge) candidate
list, e.g. "pvforecast.cec_modules". Distinct from options_from, which
reads a small already-resolved list out of config_details.
param_from:
Dotted config-field path for a secondary runtime parameter.
Used by ``"map"`` for the *keys* dropdown.
@@ -108,6 +116,7 @@ class UiHint:
# select / select_list / map
options: list[str] = field(default_factory=list)
options_from: Optional[str] = None
options_source: Optional[str] = None
param_from: Optional[str] = None
append_none: bool = False
@@ -120,14 +129,40 @@ class UiHint:
max_items_from: Optional[str] = None
# ---------------------------------------------------------------------------
# Lazy Registry
# ---------------------------------------------------------------------------
LazyOptionsSource = Callable[[], list[str]]
LAZY_OPTIONS_SOURCES: dict[str, LazyOptionsSource] = {}
def register_lazy_options_source(key: str, source: LazyOptionsSource) -> None:
"""Register a lazy options callable that provides the options list.
The callable shall provide the full candidate list for a "select_lazy" hint's
options_source.
The callable should be cheap to call repeatedly (e.g. wrapped in
caching) since it is invoked once per keystroke-driven search request
filtering happens in this process, not inside the callable itself.
"""
LAZY_OPTIONS_SOURCES[key] = source
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
UI_HINTS: dict[str, UiHint] = {
# ------------------------------------------------------------------
# Adapter - Home Assistant adapter
# Adapter
# ------------------------------------------------------------------
"adapter.provider": UiHint(
form="select_list",
options_from="adapter.providers",
),
"adapter.homeassistant.config_entity_ids": UiHint(
form="map",
options_from="adapter.homeassistant.homeassistant_entity_ids",
@@ -162,6 +197,14 @@ UI_HINTS: dict[str, UiHint] = {
options_from="adapter.homeassistant.eos_solution_entity_ids",
),
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
"database.provider": UiHint(
form="select",
options_from="database.providers",
append_none=True,
),
# ------------------------------------------------------------------
# Devices
# ------------------------------------------------------------------
"devices.batteries": UiHint(
@@ -182,16 +225,40 @@ UI_HINTS: dict[str, UiHint] = {
value_description="cycle index (0-based)",
),
# ------------------------------------------------------------------
# Electricity price — fixed time windows
# Electricity fee
# ------------------------------------------------------------------
"elecfee.provider": UiHint(
form="select",
options_from="elecfee.providers",
append_none=True,
),
"elecfee.elecfeefixed.consumption_amt_kwh.windows": UiHint(
form="time_windows",
value_description="consumption_amt_kwh [Amt/kWh]",
),
"elecfee.elecfeefixed.consumption_percent_amt.windows": UiHint(
form="time_windows",
value_description="consumption_percent_amt [%]",
),
"elecfee.elecfeefixed.feedin_amt_kwh.windows": UiHint(
form="time_windows",
value_description="feedin_amt_kwh [Amt/kWh]",
),
"elecfee.elecfeefixed.feedin_percent_amt.windows": UiHint(
form="time_windows",
value_description="feedin_percent_amt [%]",
),
# ------------------------------------------------------------------
# Electricity price
# ------------------------------------------------------------------
"elecprice.provider": UiHint(
form="select",
options_from="elecprice.providers",
append_none=True,
),
"elecprice.elecpricefixed.time_windows.windows": UiHint(
"elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows": UiHint(
form="time_windows",
value_description="electricity_price_kwh [Amt/kWh]",
value_description="elecprice_marketprice_amt_kwh [Amt/kWh]",
),
# ------------------------------------------------------------------
# EMS
@@ -201,6 +268,18 @@ UI_HINTS: dict[str, UiHint] = {
options_from="ems.modes",
),
# ------------------------------------------------------------------
# Feed-in Tariff
# ------------------------------------------------------------------
"feedintariff.provider": UiHint(
form="select",
options_from="feedintariff.providers",
append_none=True,
),
"feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows": UiHint(
form="time_windows",
value_description="feed_in_tariff_amt_kwh [Amt/kWh]",
),
# ------------------------------------------------------------------
# Load
# ------------------------------------------------------------------
"load.provider": UiHint(
@@ -238,6 +317,14 @@ UI_HINTS: dict[str, UiHint] = {
form="select",
options=["free", "building"],
),
"pvforecast.planes.inverter_model": UiHint(
form="select_lazy",
options_source="pvlib.inverters",
),
"pvforecast.planes.module_model": UiHint(
form="select_lazy",
options_source="pvlib.modules",
),
# ------------------------------------------------------------------
# Weather
# ------------------------------------------------------------------
@@ -362,6 +449,9 @@ def resolve_form_factory(
options = list(hint.options)
return make_config_update_list_form(options)
if hint.form == "select_lazy":
return make_config_update_lazy_select_form(hint.options_source or "")
if hint.form == "map":
available_values: Optional[list[str]] = None
available_keys: Optional[list[str]] = None
+14 -1
View File
@@ -24,7 +24,7 @@ from akkudoktoreos.server.dash.admin import Admin
# helpers
from akkudoktoreos.server.dash.bokeh import BokehJS
from akkudoktoreos.server.dash.components import Page
from akkudoktoreos.server.dash.configuration import Configuration
from akkudoktoreos.server.dash.configuration import Configuration, config_options
from akkudoktoreos.server.dash.context import (
IngressMiddleware,
safe_asset_path,
@@ -323,6 +323,19 @@ def post_eosdash_admin(request: Request, data: dict): # type: ignore
return Admin(*eos_server(), data)
@app.get("/eosdash/configuration/options/{options_source:path}")
def get_eosdash_configuration_options( # type: ignore
request: Request,
options_source: str,
search: str = "",
select_id: str = "",
name: str = "",
current: str = "",
):
"""Serve a filtered <select> fragment for a select_lazy config field."""
return config_options(options_source, search, select_id, name, current)
@app.get("/eosdash/configuration")
def get_eosdash_configuration(request: Request): # type: ignore
"""Serve the EOSdash Configuration page.
+16 -5
View File
@@ -1125,7 +1125,7 @@ def to_duration(
'15 minutes'
>>> to_duration("90 seconds", as_string="pandas")
'90S'
'90s'
>>> to_duration("15 minutes", as_string="{M}m")
'15m'
@@ -1170,14 +1170,18 @@ def to_duration(
logger.error(error_msg)
raise ValueError(error_msg)
# Handle strings like "2 days 5 hours 30 minutes"
matches = re.findall(r"(\d+)\s*(days?|hours?|minutes?|seconds?)", input_value)
# Handle strings like "2 days 5 hours 30 minutes" or "900.0 seconds".
# NOTE: the numeric group must match decimals (e.g. "900.0"), not just
# integers -- otherwise a run like "900.0 seconds" mismatches on the
# "." after "900" and the regex instead matches only the trailing
# "0 seconds", silently truncating the value.
matches = re.findall(r"(\d+(?:\.\d+)?)\s*(days?|hours?|minutes?|seconds?)", input_value)
if not matches:
error_msg = f"Invalid time string format '{input_value}'"
logger.error(error_msg)
raise ValueError(error_msg)
total_seconds = 0
total_seconds = 0.0
time_units = {
"day": 86400,
"hour": 3600,
@@ -1187,7 +1191,7 @@ def to_duration(
for value, unit in matches:
unit = unit.lower().rstrip("s") # Normalize unit
if unit in time_units:
total_seconds += int(value) * time_units[unit]
total_seconds += float(value) * time_units[unit]
else:
error_msg = f"Unsupported time unit: {unit}"
logger.error(error_msg)
@@ -1216,6 +1220,13 @@ def to_duration(
# Pandas frequency
if as_string == "pandas":
if total_seconds <= 0:
error_msg = (
f"Cannot express a non-positive duration ({total_seconds} seconds) "
"as a pandas frequency string."
)
logger.error(error_msg)
raise ValueError(error_msg)
# hours?
if total_seconds % 3600 == 0:
return f"{total_seconds // 3600}h"
+466 -1
View File
@@ -10,7 +10,7 @@ Timezone contract under test:
* When a timezone-aware datetime is supplied, ``start_time`` is
interpreted as wall-clock time **in that timezone** no tz
conversion is applied to ``start_time`` itself.
* Constructing a ``TimeWindow`` with an aware ``start_time`` raises
* Constructing a ``TimeWindow`` with a naive ``start_time`` raises
``ValidationError``.
"""
@@ -20,7 +20,10 @@ import sys
sys.path.insert(0, os.path.dirname(__file__))
from typing import cast
import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import ValidationError
@@ -721,6 +724,152 @@ class TestTimeWindowSequenceToArray:
assert np.all(arr_tue == 0.0) # Tuesday — all outside
# ===========================================================================
# TimeWindowSequence.to_series
# ===========================================================================
class TestTimeWindowSequenceToSeries:
"""Tests for TimeWindowSequence.to_series.
Window layout:
win1: 08:0010:00
win2: 14:0017:00
"""
def setup_method(self, method):
self.seq = TimeWindowSequence(
windows=[
make_window(8, 2),
make_window(14, 3),
]
)
def test_basic_1h_steps_naive(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert isinstance(series, pd.Series)
assert series.shape == (24,)
assert isinstance(series.index, pd.DatetimeIndex)
assert series.iloc[8] == pytest.approx(1.0)
assert series.iloc[9] == pytest.approx(1.0)
assert series.iloc[10] == pytest.approx(0.0)
assert series.iloc[14] == pytest.approx(1.0)
assert series.iloc[15] == pytest.approx(1.0)
assert series.iloc[16] == pytest.approx(1.0)
assert series.iloc[17] == pytest.approx(0.0)
def test_values_match_to_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
interval = pendulum.duration(hours=1)
arr = self.seq.to_array(start, end, interval)
series = self.seq.to_series(start, end, interval)
np.testing.assert_array_equal(series.to_numpy(), arr)
def test_dtype_is_float64(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.dtype == np.float64
def test_end_is_exclusive(self):
start = naive_dt(2024, 6, 15, 6)
end = naive_dt(2024, 6, 15, 8)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (2,)
assert list(index.hour) == [6, 7]
assert np.all(series.to_numpy() == 0.0)
def test_align_to_interval_false_preserves_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=False,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (2,)
assert index[0] == pd.Timestamp(start)
assert index[1] == pd.Timestamp(start.add(hours=1))
assert np.all(series.to_numpy() == 1.0)
def test_align_to_interval_true_floors_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
def test_aware_datetime_preserves_timezone(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (4,)
assert str(index.tz) == "Europe/Berlin"
def test_unsupported_boundary_raises(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
with pytest.raises(ValueError, match="boundary"):
self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
boundary="strict",
)
def test_empty_sequence_all_zeros(self):
seq = TimeWindowSequence()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.shape == (4,)
assert np.all(series.to_numpy() == 0.0)
# ===========================================================================
# ValueTimeWindowSequence.to_array
# ===========================================================================
@@ -881,6 +1030,254 @@ class TestValueTimeWindowSequenceToArray:
assert arr[1] == pytest.approx(0.10)
# ===========================================================================
# ValueTimeWindowSequence.to_series
# ===========================================================================
class TestValueTimeWindowSequenceToSeries:
"""Tests for ValueTimeWindowSequence.to_series."""
def setup_method(self, method):
self.seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="4 hours",
value=0.25,
),
ValueTimeWindow(
start_time="18:00:00",
duration="4 hours",
value=0.35,
),
]
)
def test_basic_1h_steps_values(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert isinstance(series, pd.Series)
assert isinstance(series.index, pd.DatetimeIndex)
assert series.shape == (24,)
assert series.iloc[8] == pytest.approx(0.25)
assert series.iloc[11] == pytest.approx(0.25)
assert series.iloc[12] == pytest.approx(0.0)
assert series.iloc[18] == pytest.approx(0.35)
assert series.iloc[21] == pytest.approx(0.35)
assert series.iloc[22] == pytest.approx(0.0)
def test_values_match_to_array(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 16, 0)
interval = pendulum.duration(hours=1)
arr = self.seq.to_array(start, end, interval)
series = self.seq.to_series(start, end, interval)
np.testing.assert_array_equal(series.to_numpy(), arr)
def test_dtype_is_float64(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.dtype == np.float64
def test_dropna_false_none_value_emits_nan(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
]
)
start = naive_dt(2024, 6, 15, 8)
end = naive_dt(2024, 6, 15, 15)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
dropna=False,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (7,)
assert list(index.hour) == [8, 9, 10, 11, 12, 13, 14]
assert np.isnan(series.iloc[0])
assert np.isnan(series.iloc[1])
assert series.iloc[2] == pytest.approx(0.0)
assert series.iloc[3] == pytest.approx(0.0)
assert series.iloc[4] == pytest.approx(0.5)
assert series.iloc[5] == pytest.approx(0.5)
assert series.iloc[6] == pytest.approx(0.0)
def test_dropna_true_none_value_omits_timestamp(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
]
)
start = naive_dt(2024, 6, 15, 8)
end = naive_dt(2024, 6, 15, 15)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
dropna=True,
)
index = cast(pd.DatetimeIndex, series.index)
# 08:00 and 09:00 are omitted completely.
assert series.shape == (5,)
assert list(index.hour) == [10, 11, 12, 13, 14]
np.testing.assert_allclose(
series.to_numpy(),
[0.0, 0.0, 0.5, 0.5, 0.0],
)
def test_dropna_no_none_values_same_result(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 6)
interval = pendulum.duration(hours=1)
series_true = self.seq.to_series(
start, end, interval, dropna=True
)
series_false = self.seq.to_series(
start, end, interval, dropna=False
)
pd.testing.assert_series_equal(series_true, series_false)
def test_aware_datetime_preserves_timezone(self):
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 4, tz="Europe/Berlin")
series = self.seq.to_series(
start, end, pendulum.duration(hours=1)
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (4,)
assert str(index.tz) == "Europe/Berlin"
def test_align_to_interval_true_floors_start(self):
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(0.25)
assert series.iloc[1] == pytest.approx(0.25)
assert series.iloc[2] == pytest.approx(0.25)
def test_align_to_interval_false_preserves_start(self):
start = naive_dt(2024, 6, 15, 8, 30)
end = naive_dt(2024, 6, 15, 12, 30)
series = self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=False,
)
assert series.shape == (4,)
assert series.index[0] == pd.Timestamp(start)
assert series.iloc[0] == pytest.approx(0.25)
assert np.all(series.to_numpy() == pytest.approx(0.25))
def test_unsupported_boundary_raises(self):
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
with pytest.raises(ValueError, match="boundary"):
self.seq.to_series(
start,
end,
pendulum.duration(hours=1),
boundary="inner",
)
def test_empty_sequence_all_zeros(self):
seq = ValueTimeWindowSequence()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.shape == (4,)
assert np.all(series.to_numpy() == 0.0)
def test_overlapping_windows_first_wins(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="4 hours",
value=0.10,
),
ValueTimeWindow(
start_time="09:00:00",
duration="4 hours",
value=0.99,
),
]
)
start = naive_dt(2024, 6, 15, 9)
end = naive_dt(2024, 6, 15, 11)
series = seq.to_series(
start, end, pendulum.duration(hours=1)
)
assert series.iloc[0] == pytest.approx(0.10)
assert series.iloc[1] == pytest.approx(0.10)
# ===========================================================================
# align_to_interval — timezone-invariance
#
@@ -957,6 +1354,22 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[1] == pytest.approx(1.0)
assert arr[2] == pytest.approx(0.0)
def test_tws_series_naive_floor_non_utc(self, set_other_timezone):
set_other_timezone()
series = self._tws_naive().to_series(
self._tws_naive_start(),
self._tws_naive_end(),
pendulum.duration(hours=1),
align_to_interval=True,
)
assert series.shape == (3,)
assert list(series.index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
# ------------------------------------------------------------------
# TimeWindowSequence — naive datetime, 30-min steps
# floor 08:10 → 08:00; expect steps 08:00(1), 08:30(1), 09:00(1), 09:30(1), 10:00(0)
@@ -1009,6 +1422,28 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[1] == pytest.approx(1.0)
assert arr[2] == pytest.approx(0.0)
def test_tws_series_aware_floor_non_utc(self, set_other_timezone):
set_other_timezone()
seq = self._tws_naive()
start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
end = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
assert series.shape == (3,)
assert list(series.index.hour) == [8, 9, 10]
assert str(series.index.tz) == "Europe/Berlin"
assert series.iloc[0] == pytest.approx(1.0)
assert series.iloc[1] == pytest.approx(1.0)
assert series.iloc[2] == pytest.approx(0.0)
# ------------------------------------------------------------------
# ValueTimeWindowSequence — naive datetime, 1-hour steps
# floor 08:10 → 08:00; values 0.25 at 08:00, 09:00; 0.0 at 10:00
@@ -1039,3 +1474,33 @@ class TestAlignToIntervalTimezoneInvariance:
assert arr[0] == pytest.approx(0.25)
assert arr[1] == pytest.approx(0.25)
assert arr[2] == pytest.approx(0.0)
def test_vtws_series_naive_floor_non_utc(self, set_other_timezone):
set_other_timezone()
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="08:00:00",
duration="2 hours",
value=0.25,
)
]
)
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
series = seq.to_series(
start,
end,
pendulum.duration(hours=1),
align_to_interval=True,
)
index = cast(pd.DatetimeIndex, series.index)
assert series.shape == (3,)
assert list(index.hour) == [8, 9, 10]
assert series.iloc[0] == pytest.approx(0.25)
assert series.iloc[1] == pytest.approx(0.25)
assert series.iloc[2] == pytest.approx(0.0)
+463
View File
@@ -0,0 +1,463 @@
"""Tests for fixed electricity fee prediction module."""
import asyncio
import json
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.config.configabc import ValueTimeWindow, ValueTimeWindowSequence
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import (
ElecFeeFixed,
ElecFeeFixedCommonSettings,
)
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECFEEFIXED_CONFIG_JSON = DIR_TESTDATA.joinpath("elecfeefixed_config.json")
class TestElecFeeFixedCommonSettings:
"""Tests for ElecFeeFixedCommonSettings model."""
def test_create_settings_with_consumption_amt_kwh(self):
"""Test creating settings with consumption_amt_kwh windows."""
settings_dict = {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00288},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0034},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.consumption_amt_kwh is not None
assert settings.consumption_amt_kwh.windows is not None
assert len(settings.consumption_amt_kwh.windows) == 2
def test_create_settings_with_consumption_percent_amt(self):
"""Test creating settings with consumption_percent_amt windows."""
settings_dict = {
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 19.0},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.consumption_percent_amt is not None
assert len(settings.consumption_percent_amt.windows) == 1
def test_create_settings_with_feedin_amt_kwh(self):
"""Test creating settings with feedin_amt_kwh windows."""
settings_dict = {
"feedin_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "8 hours", "value": 0.00008},
{"start_time": "08:00", "duration": "16 hours", "value": 0.0001},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.feedin_amt_kwh is not None
assert len(settings.feedin_amt_kwh.windows) == 2
def test_create_settings_with_feedin_percent_amt(self):
"""Test creating settings with feedin_percent_amt windows."""
settings_dict = {
"feedin_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 5.0},
]
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.feedin_percent_amt is not None
assert len(settings.feedin_percent_amt.windows) == 1
def test_create_settings_without_windows(self):
"""Test creating settings without any windows configured."""
settings = ElecFeeFixedCommonSettings()
assert settings.consumption_amt_kwh is not None
assert settings.consumption_amt_kwh.windows == []
assert settings.consumption_percent_amt is not None
assert settings.consumption_percent_amt.windows == []
assert settings.feedin_amt_kwh is not None
assert settings.feedin_amt_kwh.windows == []
assert settings.feedin_percent_amt is not None
assert settings.feedin_percent_amt.windows == []
@pytest.fixture
def elecfeefixed_settings():
"""Fully configured ElecFeeFixedCommonSettings covering all 4 sequences.
Rates are chosen to be distinguishable per sequence and per window so
that assertions can pin down exactly which value landed at which
timestamp.
"""
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
)
return ElecFeeFixedCommonSettings(
consumption_amt_kwh=consumption_amt_kwh,
consumption_percent_amt=consumption_percent_amt,
feedin_amt_kwh=feedin_amt_kwh,
feedin_percent_amt=feedin_percent_amt,
)
@pytest.fixture
def provider(config_eos, elecfeefixed_settings):
"""Fixture to create an ElecFeeFixed provider instance."""
# Assign settings to config
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
config_eos.elecfee.elecfeefixed = elecfeefixed_settings
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
class TestElecFeeFixed:
"""Tests for ElecFeeFixed provider."""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "ElecFeeFixed"
def test_singleton_instance(self, provider):
"""Test that ElecFeeFixed behaves as a singleton."""
another_instance = ElecFeeFixed()
assert provider is another_instance
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_ELECFEE__ELECFEE_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
@pytest.mark.asyncio
async def test_update_data_15min_intervals_all_sequences(self, provider, config_eos):
"""Test updating data with 15-minute intervals across all 4 fee sequences."""
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
config_eos.prediction.hours = 10 # spans both windows: 00:00-10:00 = 40 intervals
await provider.update_data(force_enable=True, force_update=True)
# 10 hours * 4 intervals per hour = 40 intervals
assert len(provider) == 40
records = provider.records
# Check timestamps are on 15-minute boundaries
for record in records:
assert record.date_time.minute in (0, 15, 30, 45)
assert record.date_time.second == 0
# --- consumption_amt_kwh -> elecfee_consumption_amt_wh (converted /1000) ---
# First 32 intervals: 00:00-08:00, night rate (8h * 4 = 32)
for i in range(32):
assert abs(records[i].elecfee_consumption_amt_wh - 0.000288) < 1e-9, (
f"Expected night consumption fee at interval {i}, "
f"got {records[i].elecfee_consumption_amt_wh}"
)
# Remaining 8 intervals: 08:00-10:00, day rate (2h * 4 = 8)
for i in range(32, 40):
assert abs(records[i].elecfee_consumption_amt_wh - 0.00034) < 1e-9, (
f"Expected day consumption fee at interval {i}, "
f"got {records[i].elecfee_consumption_amt_wh}"
)
# --- consumption_percent_amt -> elecfee_consumption_percent_amt (no conversion) ---
for i in range(40):
assert abs(records[i].elecfee_consumption_percent_amt - 19.0) < 1e-9, (
f"Expected constant consumption percent fee at interval {i}, "
f"got {records[i].elecfee_consumption_percent_amt}"
)
# --- feedin_amt_kwh -> elecfee_feedin_amt_wh (converted /1000) ---
for i in range(32):
assert abs(records[i].elecfee_feedin_amt_wh - 0.00008) < 1e-9, (
f"Expected night feedin fee at interval {i}, "
f"got {records[i].elecfee_feedin_amt_wh}"
)
for i in range(32, 40):
assert abs(records[i].elecfee_feedin_amt_wh - 0.0001) < 1e-9, (
f"Expected day feedin fee at interval {i}, "
f"got {records[i].elecfee_feedin_amt_wh}"
)
# --- feedin_percent_amt -> elecfee_feedin_percent_amt (no conversion) ---
for i in range(40):
assert abs(records[i].elecfee_feedin_percent_amt - 5.0) < 1e-9, (
f"Expected constant feedin percent fee at interval {i}, "
f"got {records[i].elecfee_feedin_percent_amt}"
)
@pytest.mark.asyncio
async def test_update_data_without_config(self, caplog, provider, config_eos):
"""Test update_data fails without any elecfeefixed configuration."""
# Remove elecfeefixed settings entirely
config_eos.elecfee.elecfeefixed = {}
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
assert "No time windows configured for `elecfee_feedin_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_feedin_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_without_time_windows(self, caplog, provider, config_eos):
"""Test update_data fails when all 4 sequences are empty."""
empty_settings = ElecFeeFixedCommonSettings(
consumption_amt_kwh=ValueTimeWindowSequence(windows=[]),
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
feedin_amt_kwh=ValueTimeWindowSequence(windows=[]),
feedin_percent_amt=ValueTimeWindowSequence(windows=[]),
)
config_eos.elecfee.elecfeefixed = empty_settings
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
assert "No time windows configured for `elecfee_feedin_amt_wh`" in caplog.text
assert "No time windows configured for `elecfee_feedin_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_missing_single_sequence(self, caplog, provider, config_eos):
"""Test that a single empty sequence among 4 still raises, naming that key.
`consumption_amt_kwh` is populated (first in insertion order), so the
loop should fail on the first sequence that is actually empty:
`consumption_percent_amt` -> `elecfee_consumption_percent_amt`.
"""
partial_settings = ElecFeeFixedCommonSettings(
consumption_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.3),
]
),
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
feedin_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.1),
]
),
feedin_percent_amt=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
),
)
config_eos.elecfee.elecfeefixed = partial_settings
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecfee_consumption_percent_amt`" in caplog.text
@pytest.mark.asyncio
async def test_key_to_array_resampling(self, provider, config_eos):
"""Test that key_to_array can resample the consumption fee to different intervals."""
# Provider provides 15-minutes data
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
config_eos.prediction.hours = 24
await provider.update_data(force_enable=True, force_update=True)
# Get data as hourly array (original)
hourly_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
fill_method="ffill",
)
assert len(hourly_array) == 24
assert abs(hourly_array[0] - 0.000288) < 1e-9 # Night rate
assert abs(hourly_array[8] - 0.00034) < 1e-9 # Day rate
# Resample to 15-minute intervals
quarter_hour_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="15 minutes",
fill_method="ffill",
)
assert len(quarter_hour_array) == 96 # 24 * 4
# First 4 15-min intervals should be night rate
for i in range(4):
assert abs(quarter_hour_array[i] - 0.000288) < 1e-9
# Resample to 30-minute intervals
half_hour_array = await provider.key_to_array(
key="elecfee_consumption_amt_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="30 minutes",
fill_method="ffill",
)
assert len(half_hour_array) == 48 # 24 * 2
# First 2 30-min intervals should be night rate
for i in range(2):
assert abs(half_hour_array[i] - 0.000288) < 1e-9
# Resample the percent-based feedin fee, which should NOT be
# kWh -> Wh converted and should be constant across the day.
percent_array = await provider.key_to_array(
key="elecfee_feedin_percent_amt",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
fill_method="ffill",
)
assert len(percent_array) == 24
assert np.allclose(percent_array, 5.0)
class TestElecFeeFixedIntegration:
"""Integration tests for ElecFeeFixed."""
@pytest.mark.skip(reason="For development only")
async def test_fixed_fee_development(self, config_eos):
"""Test fixed fee provider with real configuration."""
# Create provider with config
provider = ElecFeeFixed()
# Setup realistic test scenario
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
# Configure with realistic German electricity fees (2024)
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
]
)
config_eos.elecfee.elecfeefixed = ElecFeeFixedCommonSettings(
consumption_amt_kwh=consumption_amt_kwh,
consumption_percent_amt=consumption_percent_amt,
feedin_amt_kwh=feedin_amt_kwh,
feedin_percent_amt=feedin_percent_amt,
)
config_eos.prediction.hours = 168 # 7 days
# Update data
await provider.update_data(force_enable=True, force_update=True)
# Verify data
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals
assert len(provider) == expected_intervals
# Save configuration for documentation
config_data = {
"consumption_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.consumption_amt_kwh.windows
],
"consumption_percent_amt": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.consumption_percent_amt.windows
],
"feedin_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.feedin_amt_kwh.windows
],
"feedin_percent_amt": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value,
}
for window in config_eos.elecfee.elecfeefixed.feedin_percent_amt.windows
],
}
with FILE_TESTDATA_ELECFEEFIXED_CONFIG_JSON.open("w", encoding="utf-8") as f:
json.dump(config_data, f, indent=4)
+188
View File
@@ -0,0 +1,188 @@
"""Tests for electricity price prediction abstract/base classes.
Shared `_apply_fees`/`_store_gross_series` plumbing (empty/short-series
validation, non-uniform-spacing fallback, missing-fee-row zero-fill, key
wiring, singleton mechanics) lives in `PricePredictionProviderBase` and is
exercised generically in `test_priceabc.py` - it is not re-tested here. This
module covers what's genuinely specific to `ElecPriceProvider`: the data
record model, config-driven provider identity, and the consumption-fee
formula in `_compute_gross`.
"""
from typing import Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from akkudoktoreos.prediction.elecpriceabc import ElecPriceDataRecord, ElecPriceProvider
from akkudoktoreos.utils.datetimeutil import to_datetime
class _ElecPriceProviderForTest(ElecPriceProvider):
"""Minimal concrete subclass to exercise the abstract ElecPriceProvider base class."""
@classmethod
def provider_id(cls) -> str:
return "ElecPriceProviderForTest"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the apply_fees() tests below - they build
raw_price_amt_wh directly and never call update_data() on this
provider - but ElecPriceProvider declares _update_data as abstract,
so a concrete subclass must implement it to be instantiable at all.
"""
return None
class TestElecPriceDataRecord:
"""Tests for ElecPriceDataRecord model."""
def test_marketprice_kwh_computed_from_wh(self):
"""Test that the kWh price is the Wh price scaled by 1000."""
record = ElecPriceDataRecord(elecprice_marketprice_wh=0.0003)
assert record.elecprice_marketprice_wh == 0.0003
assert record.elecprice_marketprice_kwh is not None
assert abs(record.elecprice_marketprice_kwh - 0.3) < 1e-9
def test_marketprice_kwh_none_when_wh_none(self):
"""Test that the kWh price is None when the underlying Wh price is unset."""
record = ElecPriceDataRecord()
assert record.elecprice_marketprice_wh is None
assert record.elecprice_marketprice_kwh is None
def test_marketprice_kwh_zero_when_wh_zero(self):
"""Test that a genuine zero Wh price computes to a zero kWh price, not None."""
record = ElecPriceDataRecord(elecprice_marketprice_wh=0.0)
assert record.elecprice_marketprice_kwh == 0.0
@pytest.fixture
def provider(monkeypatch, config_eos):
"""Fixture to create a concrete ElecPriceProvider instance for testing apply_fees()."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceProviderForTest")
_ElecPriceProviderForTest.reset_instance()
return _ElecPriceProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
apply_fees() requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so apply_fees() can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_elecfee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestElecPriceProvider:
"""Tests for the ElecPriceProvider base class itself (via a minimal subclass).
Only config-driven behavior is tested here - `enabled()` wiring against
`config.elecprice.provider` specifically. Provider-identity/singleton
mechanics themselves come from PredictionMixin and are covered generically
in test_priceabc.py.
"""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "ElecPriceProviderForTest"
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
class TestElecPriceProviderApplyFees:
"""Tests for ElecPriceProvider._compute_gross(), via _apply_fees(), with keys_to_dataframe() mocked.
Only the consumption-fee formula is under test here. Input validation,
non-uniform-spacing handling, and missing-fee-row zero-fill are shared
`_apply_fees` plumbing, already covered generically in test_priceabc.py
against PricePredictionProviderBase directly.
"""
@pytest.mark.asyncio
async def test_apply_fees_combines_amt_and_percent(self, provider, monkeypatch):
"""Test combined price = (raw + per-Wh fee) * (100 + percent fee) / 100."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_elecfee = pd.DataFrame(
{
"elecfee_consumption_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"elecfee_consumption_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# Verifies _fee_keys resolves to the real consumption-fee key names,
# not just that *some* keys get passed through (already covered
# generically in test_priceabc.py).
assert set(called_kwargs["keys"]) == {
"elecfee_consumption_amt_wh",
"elecfee_consumption_percent_amt",
}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
(0.0001 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0002 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0003 + 0.00034) * (100.0 + 19.0) / 100.0,
(0.0004 + 0.00034) * (100.0 + 19.0) / 100.0,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_zero_percent_fee_passes_amt_fee_through(self, provider, monkeypatch):
"""Test that with a 0% surcharge, the result is raw price plus the per-Wh fee only."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
df_elecfee = pd.DataFrame(
{
"elecfee_consumption_amt_wh": [0.0003] * 4,
"elecfee_consumption_percent_amt": [0.0] * 4,
},
index=idx,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
expected = 0.0002 + 0.0003
for i in range(4):
assert abs(result.iloc[i] - expected) < 1e-9
+13 -4
View File
@@ -25,11 +25,20 @@ FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceAkkudoktor")
config_eos.reset_settings()
return ElecPriceAkkudoktor()
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
},
}
)
provider = ElecPriceAkkudoktor()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+337 -14
View File
@@ -4,12 +4,14 @@ from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
import requests
from loguru import logger
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
AkkudoktorElecPriceValue,
@@ -19,7 +21,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@@ -29,20 +31,46 @@ FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath(
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceEnergyCharts")
config_eos.reset_settings()
return ElecPriceEnergyCharts()
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceEnergyCharts",
"energycharts": {"bidding_zone": "DE-LU"},
},
}
)
provider = ElecPriceEnergyCharts()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def elecfee_provider(config_eos):
"""Fixture to create a ElecFeeFixed instance."""
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def sample_energycharts_json():
"""Fixture that returns sample forecast data report."""
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
"r", encoding="utf-8", newline=None
) as f_res:
input_data = json.load(f_res)
"""Fixture that returns sample forecast data report."""
return input_data
@@ -69,7 +97,7 @@ class TestElecPriceEnergyCharts:
assert not provider.enabled()
# ------------------------------------------------
# Akkudoktor
# EnergyCharts
# ------------------------------------------------
@patch("akkudoktoreos.prediction.elecpriceenergycharts.logger.error")
@@ -130,16 +158,311 @@ class TestElecPriceEnergyCharts:
@pytest.mark.asyncio
@patch("requests.get")
async def test_update_data_with_incomplete_forecast(self, mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = {"license_info": "", "unix_seconds": [], "price": [], "unit": "", "deprecated": False}
async def test_update_data_with_incomplete_forecast(self, mock_get, caplog, provider):
"""Test `_update_data` with incomplete or missing forecast data (cold start, fatal)."""
incomplete_data: dict = {
"license_info": "",
"unix_seconds": [],
"price": [],
"unit": "",
"deprecated": False
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(incomplete_data)
mock_get.return_value = mock_response
logger.info("The following errors are intentional and part of the test.")
with pytest.raises(ValueError):
with caplog.at_level("WARNING"):
with pytest.raises(ValueError, match="No Energy-Charts electricity price data available"):
await provider._update_data(force_update=True)
@pytest.mark.asyncio
async def test_update_data_keeps_quarter_hour_resolution(self, provider):
# Use a range that does not overlap the hourly fixture data used by the
# neighbouring tests; the provider is a singleton by design.
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
provider.highest_orig_datetime = None
raw_slots = provider.config.prediction.hours * 2
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
@pytest.mark.asyncio
async def test_update_data_adds_fees(self, provider, elecfee_provider, config_eos):
"""Build the gross retail price from market price and the matching Module 3 fee.
Also verifies the raw market price series stays fee-free, since it's what
ETS/median training relies on.
"""
fixed_fees_amt_kwh: float = (
0.0205 # electricity_tax
+ 0.0132 # concession_fee
+ 0.00446 # kwkg_levy
+ 0.01559 # section_19_levy
+ 0.00941 # offshore_grid_levy
)
amt_kwh: list[float] = [ # includes dynamic network fees
0.0095 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
0.1565 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
]
percent_amt: float = 19.0 # VAT %
config_eos.merge_settings_from_dict(
{
"prediction": {
"hours": 48,
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "7 hours", "value": amt_kwh[0]},
{"start_time": "07:00", "duration": "8 hours", "value": amt_kwh[1]},
{"start_time": "15:00", "duration": "5 hours", "value": amt_kwh[2]},
{"start_time": "20:00", "duration": "4 hours", "value": amt_kwh[3]},
],
},
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": percent_amt},
],
},
},
},
},
)
ems_eos = get_ems()
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start)
# Create fees prediction
await elecfee_provider._update_data(force_update=True)
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
price=[100.0] * len(timestamps),
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
# Raw series must stay pure market price, unaffected by fees, at every
# timestamp - including the ones covered by the ETS/median-predicted tail.
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
raw_result_kwh = raw_result * 1000
slots_for_test = (0*4, 7*4, 15*4, 20*4)
for slot in slots_for_test:
assert raw_result_kwh.iloc[slot] == pytest.approx(0.1)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
result_kwh = result * 1000
rate_amt = 1.0 + percent_amt / 100.0
for idx, slot in enumerate(slots_for_test):
assert result_kwh.iloc[slot] == pytest.approx((raw_result_kwh.iloc[slot] + amt_kwh[idx]) * rate_amt)
@pytest.mark.asyncio
async def test_update_data_applies_fees_to_predicted_tail(self, provider, elecfee_provider, config_eos):
"""Predicted timestamps beyond the fetched data must still get fees applied.
Regression test for a bug where the ETS/median-extrapolated tail of the
series was written to elecprice_marketprice_wh without ever going through
apply_fees(), silently dropping VAT and all fee components for any
timestamp past what Energy-Charts had actually published.
"""
fixed_fees_amt_kwh: float = (
0.0205 # electricity_tax
+ 0.0132 # concession_fee
+ 0.00446 # kwkg_levy
+ 0.01559 # section_19_levy
+ 0.00941 # offshore_grid_levy
)
amt_kwh: list[float] = [ # includes dynamic network fees
0.0095 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
0.1565 + fixed_fees_amt_kwh,
0.0953 + fixed_fees_amt_kwh,
]
percent_amt: float = 19.0 # VAT %
config_eos.merge_settings_from_dict(
{
"prediction": {
"hours": 48,
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "7 hours", "value": amt_kwh[0]},
{"start_time": "07:00", "duration": "8 hours", "value": amt_kwh[1]},
{"start_time": "15:00", "duration": "5 hours", "value": amt_kwh[2]},
{"start_time": "20:00", "duration": "4 hours", "value": amt_kwh[3]},
],
},
"consumption_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": percent_amt},
],
},
},
},
"elecprice": {
"provider": "ElecPriceEnergyCharts",
},
},
)
ems_eos = get_ems()
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start)
await elecfee_provider._update_data(force_update=True)
# Only 4 known market-price points, spanning just 20 hours of day 1.
# With a 48h prediction horizon, everything from hour 21 onward has to
# come from the median/ETS fallback rather than from the mocked API data.
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
price=[100.0] * len(timestamps), # 100 EUR/MWh = 0.1 EUR/kWh
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
# Day 2, 06:00 - inside the predicted (non-fetched) range, and inside the
# same 00:00-07:00 fee window as amt_kwh[0] on day 1.
predicted_timestamp = start.add(hours=30)
assert predicted_timestamp <= start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=predicted_timestamp,
end_datetime=predicted_timestamp.add(minutes=15),
interval=to_duration("15 minutes"),
)
raw_result_kwh = raw_result * 1000
# All four known market prices were equal (0.1 EUR/kWh); ETS on a flat
# series should stay close to that, allowing for optimizer noise.
assert raw_result_kwh.iloc[0] == pytest.approx(0.1, abs=0.01)
result = await provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=predicted_timestamp,
end_datetime=predicted_timestamp.add(minutes=15),
interval=to_duration("15 minutes"),
)
result_kwh = result * 1000
rate_amt = 1.0 + percent_amt / 100.0
# Derived from the actually-measured raw value above, not a hardcoded
# 0.1, so this checks fee application on the real predicted price
# rather than re-asserting what the ETS prediction should be.
assert result_kwh.iloc[0] == pytest.approx((raw_result_kwh.iloc[0] + amt_kwh[0]) * rate_amt)
@pytest.mark.asyncio
async def test_update_data_covers_full_horizon_after_stale_fetch_outage(self, provider):
"""Regression test: needed_slots must include the gap when a fetch outage
leaves highest_orig_datetime behind the current ems_start_datetime.
Before the fix, `covered_slots` was clamped to 0 whenever
highest_orig_datetime was older than ems_start_datetime, instead of
being allowed to go negative. That left `needed_slots` at only
`prediction.hours * slots_per_hour`, so the predicted tail only
reached `highest_orig_datetime + prediction.hours` - ending before
the actually-requested `ems_start_datetime + prediction.hours`
whenever an outage persisted long enough for the two to diverge.
"""
provider.config.prediction.hours = 48
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
# Seed enough 15-minute history for the weekly-ETS branch of _predict.
raw_start = start.subtract(days=35)
raw_slots = int((start - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
def fake_ets(history, seasonal_periods, hours):
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data),
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
last_good = provider.highest_orig_datetime
assert last_good is not None
# Advance ems_start_datetime well past the last known data point, as
# if a fetch outage has persisted for a while - highest_orig_datetime
# is now *before* ems_start_datetime, not just close behind it.
outage_gap_hours = 20
new_start = to_datetime(last_good).add(hours=outage_gap_hours)
get_ems().set_start_datetime(new_start)
with (
patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
),
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
# Fallback kept the stale history rather than raising (cold-start
# fatality only applies when there's no history at all).
assert provider.highest_orig_datetime == last_good
# The predicted series must reach the end of the horizon measured
# from the *current* ems_start_datetime - i.e. it must also backfill
# the outage_gap_hours gap, not just prediction.hours beyond the
# stale highest_orig_datetime.
horizon_end = new_start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="elecprice_marketprice_raw_wh",
start_datetime=horizon_end.subtract(minutes=15),
end_datetime=horizon_end,
interval=to_duration("15 minutes"),
)
assert len(raw_result) == 1
assert not raw_result.isna().any()
@pytest.mark.parametrize(
"status_code, exception",
@@ -226,8 +549,8 @@ class TestElecPriceEnergyCharts:
f"Bidding zone in URL looks like an enum repr: '{bzn_value}'. "
f"Use .value when building the URL, not str(enum)."
)
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone.value, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone.value}' "
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone}' "
f"but got bzn='{bzn_value}' in URL: {actual_url}"
)
+32 -26
View File
@@ -28,7 +28,7 @@ class TestElecPriceFixedCommonSettings:
def test_create_settings_with_windows(self):
"""Test creating settings with time windows."""
settings_dict = {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"start_time": "00:00",
@@ -46,25 +46,30 @@ class TestElecPriceFixedCommonSettings:
settings = ElecPriceFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.time_windows is not None
assert settings.time_windows.windows is not None
assert len(settings.time_windows.windows) == 2
assert settings.elecprice_marketprice_amt_kwh is not None
assert settings.elecprice_marketprice_amt_kwh.windows is not None
assert len(settings.elecprice_marketprice_amt_kwh.windows) == 2
def test_create_settings_without_windows(self):
"""Test creating settings without time windows."""
settings = ElecPriceFixedCommonSettings()
assert settings.time_windows is not None
assert settings.time_windows.windows == []
assert settings.elecprice_marketprice_amt_kwh is not None
assert settings.elecprice_marketprice_amt_kwh.windows == []
@pytest.fixture
def provider(monkeypatch, config_eos):
def provider(config_eos):
"""Fixture to create a ElecPriceFixed provider instance."""
# Set environment variables
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceFixed")
# Create settings and assign to config
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceFixed",
},
}
)
# Create time windows
time_windows = ValueTimeWindowSequence(
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
@@ -78,12 +83,11 @@ def provider(monkeypatch, config_eos):
)
]
)
# Create settings and assign to config
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
ElecPriceFixed.reset_instance()
return ElecPriceFixed()
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
provider = ElecPriceFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
@@ -144,23 +148,25 @@ class TestElecPriceFixed:
)
@pytest.mark.asyncio
async def test_update_data_without_config(self, provider, config_eos):
async def test_update_data_without_config(self, caplog, provider, config_eos):
"""Test update_data fails without configuration."""
# Remove elecpricefixed settings
config_eos.elecprice.elecpricefixed = {}
with pytest.raises(ValueError, match="No time windows configured"):
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
@pytest.mark.asyncio
async def test_update_data_without_time_windows(self, provider, config_eos):
async def test_update_data_without_elecprice_marketprice_amt_kwh(self, caplog, provider, config_eos):
"""Test update_data fails without time windows."""
# Set empty time windows
empty_settings = ElecPriceFixedCommonSettings(time_windows=ValueTimeWindowSequence(windows=[]))
empty_settings = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=ValueTimeWindowSequence(windows=[]))
config_eos.elecprice.elecpricefixed = empty_settings
with pytest.raises(ValueError, match="No time windows configured"):
with caplog.at_level("WARNING"):
await provider.update_data(force_enable=True, force_update=True)
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
@pytest.mark.asyncio
async def test_key_to_array_resampling(self, provider, config_eos):
@@ -230,7 +236,7 @@ class TestElecPriceFixedIntegration:
ems_eos.set_start_datetime(start_dt)
# Configure with realistic German electricity prices (2024)
time_windows = ValueTimeWindowSequence(
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
@@ -245,7 +251,7 @@ class TestElecPriceFixedIntegration:
]
)
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
config_eos.prediction.hours = 168 # 7 days
# Update data
@@ -257,13 +263,13 @@ class TestElecPriceFixedIntegration:
# Save configuration for documentation
config_data = {
"time_windows": [
"elecprice_marketprice_amt_kwh": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value
}
for window in config_eos.elecprice.elecpricefixed.time_windows.windows
for window in config_eos.elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows
]
}
+79
View File
@@ -0,0 +1,79 @@
# 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")
get_ems().set_start_datetime(
to_datetime("2026-07-27 00:00:00", in_timezone="Europe/Berlin")
)
provider = ElecPriceSMARD()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
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()
+2 -2
View File
@@ -56,14 +56,14 @@ def _tibber_payload(
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber electricity price provider."""
ElecPriceTibber.reset_instance()
config_eos.elecprice = ElecPriceCommonSettings(
provider="ElecPriceTibber",
tibber=ElecPriceTibberCommonSettings(access_token="token-123", home_id="home-1"),
)
config_eos.prediction.hours = 6
provider = ElecPriceTibber()
provider.records.clear()
assert provider.enabled()
provider._db_reset_state()
return provider
+191
View File
@@ -0,0 +1,191 @@
"""Tests for feed in tariff prediction abstract/base classes.
Shared `_apply_fees`/`_store_gross_series` plumbing (empty/short-series
validation, non-uniform-spacing fallback, missing-fee-row zero-fill, key
wiring, singleton mechanics) lives in `PricePredictionProviderBase` and is
exercised generically in `test_priceabc.py` - it is not re-tested here. This
module covers what's genuinely specific to `FeedInTariffProvider`: the data
record model, config-driven provider identity, and the feed-in-fee formula in
`_compute_gross`.
"""
from typing import Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from akkudoktoreos.prediction.feedintariffabc import (
FeedInTariffDataRecord,
FeedInTariffProvider,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
class _FeedInTariffProviderForTest(FeedInTariffProvider):
"""Minimal concrete subclass to exercise the abstract FeedInTariffProvider base class."""
@classmethod
def provider_id(cls) -> str:
return "FeedInTariffProviderForTest"
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the apply_fees() tests below - they build
raw_price_amt_wh directly and never call update_data() on this
provider - but FeedInTariffProvider declares _update_data as abstract,
so a concrete subclass must implement it to be instantiable at all.
"""
return None
class TestFeedInTariffDataRecord:
"""Tests for FeedInTariffDataRecord model."""
def test_tariff_kwh_computed_from_wh(self):
"""Test that the kWh tariff is the Wh tariff scaled by 1000."""
record = FeedInTariffDataRecord(feed_in_tariff_wh=0.0003)
assert record.feed_in_tariff_wh == 0.0003
assert record.feed_in_tariff_kwh is not None
assert abs(record.feed_in_tariff_kwh - 0.3) < 1e-9
def test_tariff_kwh_none_when_wh_none(self):
"""Test that the kWh tariff is None when the underlying Wh tariff is unset."""
record = FeedInTariffDataRecord()
assert record.feed_in_tariff_wh is None
assert record.feed_in_tariff_kwh is None
def test_tariff_kwh_zero_when_wh_zero(self):
"""Test that a genuine zero Wh tariff computes to a zero kWh tariff, not None."""
record = FeedInTariffDataRecord(feed_in_tariff_wh=0.0)
assert record.feed_in_tariff_kwh == 0.0
@pytest.fixture
def provider(monkeypatch, config_eos):
"""Fixture to create a concrete FeedInTariffProvider instance for testing apply_fees()."""
monkeypatch.setenv("EOS_FEEDINTARIFF__FEEDINTARIFF_PROVIDER", "FeedInTariffProviderForTest")
_FeedInTariffProviderForTest.reset_instance()
return _FeedInTariffProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
apply_fees() requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so apply_fees() can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_elecfee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestFeedInTariffProvider:
"""Tests for the FeedInTariffProvider base class itself (via a minimal subclass).
Only config-driven behavior is tested here - `enabled()` wiring against
`config.feedintariff.provider` specifically. Provider-identity/singleton
mechanics themselves come from PredictionMixin and are covered generically
in test_priceabc.py.
"""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "FeedInTariffProviderForTest"
def test_invalid_provider(self, provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_FEEDINTARIFF__FEEDINTARIFF_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
class TestFeedInTariffProviderApplyFees:
"""Tests for FeedInTariffProvider._compute_gross(), via _apply_fees(), with keys_to_dataframe() mocked.
Only the feed-in-fee formula is under test here. Input validation,
non-uniform-spacing handling, and missing-fee-row zero-fill are shared
`_apply_fees` plumbing, already covered generically in test_priceabc.py
against PricePredictionProviderBase directly.
"""
@pytest.mark.asyncio
async def test_apply_fees_combines_amt_and_percent(self, provider, monkeypatch):
"""Test combined tariff = raw * (100 - percent fee) / 100 - per-Wh fee."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_elecfee = pd.DataFrame(
{
"elecfee_feedin_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"elecfee_feedin_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# Verifies _fee_keys resolves to the real feed-in-fee key names, not
# just that *some* keys get passed through (already covered
# generically in test_priceabc.py).
assert set(called_kwargs["keys"]) == {
"elecfee_feedin_amt_wh",
"elecfee_feedin_percent_amt",
}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
0.0001 * (100.0 - 19.0) / 100.0 - 0.000288,
0.0002 * (100.0 - 19.0) / 100.0 - 0.000288,
0.0003 * (100.0 - 19.0) / 100.0 - 0.00034,
0.0004 * (100.0 - 19.0) / 100.0 - 0.00034,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_zero_percent_fee_subtracts_amt_fee_only(self, provider, monkeypatch):
"""Test that with a 0% deduction, the result is raw tariff minus the per-Wh fee only."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
df_elecfee = pd.DataFrame(
{
"elecfee_feedin_amt_wh": [0.0003] * 4,
"elecfee_feedin_percent_amt": [0.0] * 4,
},
index=idx,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_elecfee)
result = await provider._apply_fees(raw_price_amt_wh)
expected = 0.0002 - 0.0003
for i in range(4):
assert abs(result.iloc[i] - expected) < 1e-9
+5 -6
View File
@@ -15,15 +15,14 @@ from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def provider(config_eos):
config_eos.merge_settings_from_dict(
{
"elecprice": {"charges_kwh": 0.30},
"feedintariff": {"provider": "FeedInTariffAkkudoktor"},
}
)
value = FeedInTariffAkkudoktor()
value.highest_orig_datetime = None
value.records.clear()
assert value.enabled()
return value
provider = FeedInTariffAkkudoktor()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+4 -1
View File
@@ -25,7 +25,10 @@ def provider(config_eos):
config_eos.merge_settings_from_dict(
{"feedintariff": {"provider": "FeedInTariffDvhubOnline"}}
)
return FeedInTariffDvhubOnline()
provider = FeedInTariffDvhubOnline()
assert provider.enabled()
provider._db_reset_state()
return provider
class TestFeedInTariffDvhubOnline:
+190 -10
View File
@@ -5,16 +5,17 @@ from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
import requests
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@@ -35,8 +36,25 @@ def provider(config_eos):
)
provider = FeedInTariffEnergyCharts()
provider.highest_orig_datetime = None
provider.records.clear()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def elecfee_provider(config_eos):
"""Fixture to create a ElecFeeFixed instance."""
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
},
}
)
provider = ElecFeeFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
@@ -94,7 +112,7 @@ class TestFeedInTariffEnergyCharts:
await provider._update_data(force_update=True)
result = await provider.key_to_raw_series(
key="feed_in_tariff_wh",
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
)
@@ -127,9 +145,9 @@ class TestFeedInTariffEnergyCharts:
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data) as request,
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(
ElecPriceEnergyCharts,
FeedInTariffEnergyCharts,
"_predict_median",
side_effect=AssertionError("median fallback must not be used"),
),
@@ -141,7 +159,7 @@ class TestFeedInTariffEnergyCharts:
# second update reuses the retained 35-day history without another request.
assert request.call_count == 1
assert len(ets_history_lengths) == 2
assert all(length > 800 * 4 for length, _ in ets_history_lengths)
assert all(length > 2 * 168 * 4 for length, _ in ets_history_lengths)
assert all(seasonal_periods == 168 * 4 for _, seasonal_periods in ets_history_lengths)
await provider.update_data(force_enable=True, force_update=True)
@@ -185,10 +203,10 @@ class TestFeedInTariffEnergyCharts:
deprecated=False,
)
def fake_predict(history, slots, slots_per_hour):
return np.full(slots, 0.00005)
def fake_predict(history, hours, slots_per_hour=1):
return np.full(hours, 0.00005)
with patch.object(provider, "_predict_prices", side_effect=fake_predict):
with patch.object(provider, "_predict", side_effect=fake_predict):
# First: successful update seeds history and highest_orig_datetime.
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider.update_data(force_enable=True, force_update=True)
@@ -218,3 +236,165 @@ class TestFeedInTariffEnergyCharts:
):
with pytest.raises(requests.exceptions.ReadTimeout):
await provider.update_data(force_enable=True, force_update=True)
@pytest.mark.asyncio
async def test_update_data_no_fees_configured_defaults_gross_to_raw(self, provider):
"""Without an ElecFee provider configured, feed_in_tariff_wh must equal the raw series.
_apply_fees() catches the KeyError from an absent ElecFee provider and
defaults both fee components to 0, so gross should be indistinguishable
from raw in that case.
"""
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
raw_slots = provider.config.prediction.hours * 4
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
raw = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
gross = await provider.key_to_series(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
interval=to_duration("15 minutes"),
)
pd.testing.assert_series_equal(gross, raw, check_names=False)
@pytest.mark.asyncio
async def test_update_data_applies_feedin_fees(self, provider, config_eos):
"""feed_in_tariff_wh must reflect the configured feed-in fee deduction.
Per _apply_fees(): gross = raw * (100 - percent_amt) / 100 - amt_wh,
i.e. fees are deducted from what the producer receives, the inverse
direction of the consumption-side markup.
"""
feedin_amt_kwh = 0.001 # flat fee/kWh deducted from the feed-in payout
feedin_percent_amt = 5.0 # percentage deducted from the feed-in payout
config_eos.merge_settings_from_dict(
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"feedin_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": feedin_amt_kwh},
],
},
"feedin_percent_amt": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": feedin_percent_amt},
],
},
},
},
}
)
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
await ElecFeeFixed()._update_data(force_update=True)
raw_slots = provider.config.prediction.hours * 4
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots, # 100 EUR/MWh = 0.1 EUR/kWh
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
await provider._update_data(force_update=True)
raw_result = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=start,
end_datetime=start.add(minutes=15),
interval=to_duration("15 minutes"),
)
gross_result = await provider.key_to_series(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(minutes=15),
interval=to_duration("15 minutes"),
)
raw_kwh = raw_result.iloc[0] * 1000
gross_kwh = gross_result.iloc[0] * 1000
assert raw_kwh == pytest.approx(0.1)
expected_gross_kwh = raw_kwh * (100.0 - feedin_percent_amt) / 100.0 - feedin_amt_kwh
assert gross_kwh == pytest.approx(expected_gross_kwh)
@pytest.mark.asyncio
async def test_update_data_covers_full_horizon_after_stale_fetch_outage(self, provider):
"""Regression test: needed_slots must include the gap when a fetch outage
leaves highest_orig_datetime behind the current ems_start_datetime.
Same bug/fix as ElecPriceEnergyCharts: `covered_slots` must be allowed
to go negative when highest_orig_datetime is older than
ems_start_datetime, rather than clamped to 0, or the predicted tail
ends before ems_start_datetime + prediction.hours whenever an outage
persists long enough for the two to diverge.
"""
provider.config.prediction.hours = 48
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
raw_start = start.subtract(days=35)
raw_slots = int((start - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
def fake_ets(history, seasonal_periods, hours):
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
last_good = provider.highest_orig_datetime
assert last_good is not None
# Advance ems_start_datetime well past the last known data point, as
# if a fetch outage has persisted for a while.
outage_gap_hours = 20
new_start = to_datetime(last_good).add(hours=outage_gap_hours)
get_ems().set_start_datetime(new_start)
with (
patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
),
patch.object(FeedInTariffEnergyCharts, "_predict_ets", side_effect=fake_ets),
):
await provider.update_data(force_enable=True, force_update=True)
assert provider.highest_orig_datetime == last_good
horizon_end = new_start.add(hours=provider.config.prediction.hours)
raw_result = await provider.key_to_series(
key="feed_in_tariff_raw_wh",
start_datetime=horizon_end.subtract(minutes=15),
end_datetime=horizon_end,
interval=to_duration("15 minutes"),
)
assert len(raw_result) == 1
assert not raw_result.isna().any()
+7 -3
View File
@@ -12,19 +12,23 @@ DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@pytest.fixture
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
"""Fixture to create a FeedInTariffProvider instance."""
settings = {
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078,
"feed_in_tariff_amt_kwh": {
"windows": [
{"start_time": "00:00", "duration": "24 hours", "value": 0.078},
],
},
},
}
}
config_eos.merge_settings_from_dict(settings)
assert config_eos.feedintariff.provider == "FeedInTariffFixed"
provider = FeedInTariffFixed()
assert provider.enabled()
provider._db_reset_state()
return provider
+41
View File
@@ -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)
+4 -4
View File
@@ -45,7 +45,6 @@ def quarter_hour_points() -> list[dict]:
@pytest.fixture
def provider(config_eos):
"""Create a fresh Tibber feed-in tariff provider."""
FeedInTariffTibber.reset_instance()
config_eos.merge_settings_from_dict(
{
"elecprice": {"tibber": {"access_token": "token-123", "home_id": "home-1"}},
@@ -55,12 +54,13 @@ def provider(config_eos):
"prediction": {"hours": 2},
}
)
provider = FeedInTariffTibber()
provider.records.clear()
provider.highest_orig_datetime = None
get_ems().set_start_datetime(
to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
)
provider = FeedInTariffTibber()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
+38 -22
View File
@@ -2,16 +2,20 @@ import pytest
from pydantic import ValidationError
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixed
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImport
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.feedintariffdvhubonline import FeedInTariffDvhubOnline
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,
@@ -50,16 +54,20 @@ def forecast_providers():
WeatherClearOutside(),
WeatherImport(),
WeatherOpenMeteo(),
ElecFeeFixed(),
ElecFeeImport(),
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceFixed(),
ElecPriceImport(),
ElecPriceSMARD(),
ElecPriceTibber(),
FeedInTariffAkkudoktor(),
FeedInTariffDvhubOnline(),
FeedInTariffEnergyCharts(),
FeedInTariffFixed(),
FeedInTariffImport(),
FeedInTariffSMARD(),
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
@@ -108,28 +116,32 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[1], WeatherClearOutside)
assert isinstance(prediction.providers[2], WeatherImport)
assert isinstance(prediction.providers[3], WeatherOpenMeteo)
assert isinstance(prediction.providers[4], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[5], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[6], ElecPriceFixed)
assert isinstance(prediction.providers[7], ElecPriceImport)
assert isinstance(prediction.providers[8], ElecPriceTibber)
assert isinstance(prediction.providers[9], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[10], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[11], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[12], FeedInTariffFixed)
assert isinstance(prediction.providers[13], FeedInTariffImport)
assert isinstance(prediction.providers[14], FeedInTariffTibber)
assert isinstance(prediction.providers[15], LoadAkkudoktor)
assert isinstance(prediction.providers[16], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[17], LoadImport)
assert isinstance(prediction.providers[18], LoadVrm)
assert isinstance(prediction.providers[19], PVForecastAkkudoktor)
assert isinstance(prediction.providers[20], PVForecastForecastSolar)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], PVForecastPVLib)
assert isinstance(prediction.providers[23], PVForecastPVNode)
assert isinstance(prediction.providers[24], PVForecastSolcast)
assert isinstance(prediction.providers[25], PVForecastVrm)
assert isinstance(prediction.providers[4], ElecFeeFixed)
assert isinstance(prediction.providers[5], ElecFeeImport)
assert isinstance(prediction.providers[6], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[7], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[8], ElecPriceFixed)
assert isinstance(prediction.providers[9], ElecPriceImport)
assert isinstance(prediction.providers[10], ElecPriceSMARD)
assert isinstance(prediction.providers[11], ElecPriceTibber)
assert isinstance(prediction.providers[12], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[13], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[14], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[15], FeedInTariffFixed)
assert isinstance(prediction.providers[16], FeedInTariffImport)
assert isinstance(prediction.providers[17], FeedInTariffSMARD)
assert isinstance(prediction.providers[18], FeedInTariffTibber)
assert isinstance(prediction.providers[19], LoadAkkudoktor)
assert isinstance(prediction.providers[20], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[21], LoadImport)
assert isinstance(prediction.providers[22], LoadVrm)
assert isinstance(prediction.providers[23], PVForecastAkkudoktor)
assert isinstance(prediction.providers[24], PVForecastForecastSolar)
assert isinstance(prediction.providers[25], PVForecastImport)
assert isinstance(prediction.providers[26], PVForecastPVLib)
assert isinstance(prediction.providers[27], PVForecastPVNode)
assert isinstance(prediction.providers[28], PVForecastSolcast)
assert isinstance(prediction.providers[29], PVForecastVrm)
def test_provider_by_id(prediction, forecast_providers):
@@ -142,16 +154,20 @@ def test_prediction_repr(prediction):
"""Test that the Prediction instance's representation is correct."""
result = repr(prediction)
assert "Prediction([" in result
assert "ElecFeeFixed" in result
assert "ElecFeeImport" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "ElecPriceSMARD" in result
assert "ElecPriceTibber" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffDvhubOnline" in result
assert "FeedInTariffEnergyCharts" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffSMARD" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadAkkudoktorAdjusted" in result
+316
View File
@@ -0,0 +1,316 @@
"""Tests for the shared price prediction base class (PricePredictionProviderBase).
Covers the logic that lives in `priceabc.py` itself - the forecasting helpers,
`_apply_fees` plumbing (index normalization, fee fetch/fallback, zero-fill), and
`_store_gross_series` wiring via the `_raw_key`/`_gross_key`/`_fee_keys`/
`_compute_gross` hooks - independent of any concrete provider's fee formula.
Provider-specific tests (the actual `_compute_gross` formula for electricity
price vs. feed-in tariff, and end-to-end behavior with a real fee provider)
belong in `test_elecpriceabc.py` / `test_feedintariffabc.py` /
`test_elecpricenergycharts.py` instead.
"""
from typing import List, Optional
from unittest.mock import AsyncMock
import pandas as pd
import pytest
from pydantic import Field
from akkudoktoreos.prediction.predictionabc import PredictionRecord
from akkudoktoreos.prediction.priceabc import PricePredictionProviderBase
from akkudoktoreos.utils.datetimeutil import to_datetime
class _PriceProviderForTest(PricePredictionProviderBase):
"""Minimal concrete subclass to exercise PricePredictionProviderBase directly.
Implements `_compute_gross` with the same add-then-percent formula as
ElecPriceProvider, but that choice is incidental here - these tests target
the shared plumbing in `_apply_fees`/`_store_gross_series`, not the formula
itself, so any well-defined formula would do.
"""
records: List[PredictionRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of PredictionRecord records"},
)
@classmethod
def provider_id(cls) -> str:
return "PriceProviderForTest"
def enabled(self) -> bool:
return True
async def _update_data(self, force_update: Optional[bool] = False) -> None:
"""No-op update.
Not exercised by the tests below - they either build raw price series
directly or mock `key_to_raw_series`/`key_from_series` - but
`PredictionProvider` declares `_update_data` as abstract, so a concrete
subclass must implement it to be instantiable at all.
"""
return None
@property
def _raw_key(self) -> str:
return "test_price_raw_wh"
@property
def _gross_key(self) -> str:
return "test_price_wh"
@property
def _fee_keys(self) -> list[str]:
return ["test_fee_amt_wh", "test_fee_percent_amt"]
def _compute_gross(self, raw_amt_wh: pd.Series, df_fee: pd.DataFrame) -> pd.Series:
return (
(raw_amt_wh + df_fee["test_fee_amt_wh"])
* (100.0 + df_fee["test_fee_percent_amt"])
/ 100.0
)
@pytest.fixture
def provider(config_eos):
"""Fixture to create a concrete PricePredictionProviderBase instance for testing."""
_PriceProviderForTest.reset_instance()
return _PriceProviderForTest()
def _patch_keys_to_dataframe(monkeypatch, provider, df_fee: pd.DataFrame) -> AsyncMock:
"""Monkeypatch Prediction.keys_to_dataframe to return fixed fee data.
`_apply_fees` requires a real fee provider to already be registered and
have generated data in the prediction registry for keys_to_dataframe to
return anything - which we sidestep here by mocking the call directly,
so `_apply_fees` can be tested in isolation.
provider.prediction is a pydantic model with validate_assignment enabled,
so assigning directly onto the *instance* (`provider.prediction.keys_to_dataframe
= mock`) is rejected by pydantic - keys_to_dataframe is a real method, not
a declared field. Patching the *class* method instead is plain attribute
replacement and bypasses pydantic's __setattr__ validation.
"""
mock = AsyncMock(return_value=df_fee)
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
return mock
class TestPricePredictionProviderBase:
"""Tests for the base class itself (via a minimal concrete subclass)."""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "PriceProviderForTest"
def test_singleton_instance(self, provider):
"""Test that the concrete provider behaves as a singleton."""
another_instance = _PriceProviderForTest()
assert provider is another_instance
class TestPricePredictionProviderBaseApplyFeesValidation:
"""Tests for input validation in PricePredictionProviderBase._apply_fees()."""
@pytest.mark.asyncio
async def test_apply_fees_empty_series_raises(self, provider):
"""Test that an empty raw price series is rejected outright."""
empty_series = pd.Series([], dtype=float)
with pytest.raises(ValueError, match="must not be empty"):
await provider._apply_fees(empty_series)
@pytest.mark.asyncio
async def test_apply_fees_single_entry_series_raises(self, provider):
"""Test that a single-entry series has no interval to derive and is rejected."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
series = pd.Series([0.0003], index=pd.DatetimeIndex([start_dt]))
with pytest.raises(ValueError, match="at least two entries"):
await provider._apply_fees(series)
@pytest.mark.asyncio
async def test_apply_fees_non_uniform_interval_warns(self, caplog, provider):
"""Test that a series whose timestamps are not evenly spaced falls back to
a fixed 15-minute grid, with a warning, instead of raising."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex(
[start_dt, start_dt.add(minutes=15), start_dt.add(minutes=50)]
)
series = pd.Series([0.0003, 0.00031, 0.00032], index=idx)
with caplog.at_level("WARNING"):
await provider._apply_fees(series)
assert "raw_price_amt_wh has non uniform spacing" in caplog.text
class TestPricePredictionProviderBaseApplyFees:
"""Tests for PricePredictionProviderBase._apply_fees(), with keys_to_dataframe() mocked.
Uses the generic `_compute_gross` formula from `_PriceProviderForTest`
(structurally identical to ElecPriceProvider's), since the point here is to
verify the shared fetch/reindex/fill plumbing feeds `_compute_gross`
correctly - not to re-verify any one provider's formula.
"""
@pytest.mark.asyncio
async def test_apply_fees_calls_compute_gross_with_fetched_fees(self, provider, monkeypatch):
"""Test combined price = (raw + amt fee) * (100 + percent fee) / 100."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx, name="raw_price")
df_fee = pd.DataFrame(
{
"test_fee_amt_wh": [0.000288, 0.000288, 0.00034, 0.00034],
"test_fee_percent_amt": [19.0, 19.0, 19.0, 19.0],
},
index=idx,
)
mock = _patch_keys_to_dataframe(monkeypatch, provider, df_fee)
result = await provider._apply_fees(raw_price_amt_wh)
assert mock.await_count == 1
assert mock.await_args
called_kwargs = mock.await_args.kwargs
# The fee keys fetched must come from the `_fee_keys` hook, not be hardcoded.
assert set(called_kwargs["keys"]) == {"test_fee_amt_wh", "test_fee_percent_amt"}
assert called_kwargs["start_datetime"] == start_dt
assert called_kwargs["boundary"] == "context"
assert called_kwargs["align_to_interval"] is True
assert result.name == "raw_price"
assert len(result) == 4
assert not result.isna().any()
expected = [
(0.0001 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0002 + 0.000288) * (100.0 + 19.0) / 100.0,
(0.0003 + 0.00034) * (100.0 + 19.0) / 100.0,
(0.0004 + 0.00034) * (100.0 + 19.0) / 100.0,
]
for i, exp in enumerate(expected):
assert abs(result.iloc[i] - exp) < 1e-9, (
f"interval {i}: expected {exp}, got {result.iloc[i]}"
)
@pytest.mark.asyncio
async def test_apply_fees_missing_fee_provider_falls_back_to_zero(self, provider, monkeypatch):
"""Test that a KeyError from keys_to_dataframe (no fee provider configured)
is treated as zero fees rather than propagating."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_price_amt_wh = pd.Series([0.0002] * 4, index=idx)
mock = AsyncMock(side_effect=KeyError("no fee provider configured"))
monkeypatch.setattr(type(provider.prediction), "keys_to_dataframe", mock)
result = await provider._apply_fees(raw_price_amt_wh)
# Zero amt fee, zero percent fee -> raw price passes through unchanged.
for i in range(4):
assert abs(result.iloc[i] - 0.0002) < 1e-9
@pytest.mark.asyncio
async def test_apply_fees_missing_fee_rows_filled_with_zero(self, provider, monkeypatch):
"""Test that timestamps not covered by the fee data get a zero fee, not NaN."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
idx_full = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
# Fee data only covers the first two of the four raw price timestamps.
idx_partial = idx_full[:2]
raw_price_amt_wh = pd.Series([0.0001, 0.0001, 0.0001, 0.0001], index=idx_full)
df_fee = pd.DataFrame(
{
"test_fee_amt_wh": [0.000288, 0.000288],
"test_fee_percent_amt": [19.0, 19.0],
},
index=idx_partial,
)
_patch_keys_to_dataframe(monkeypatch, provider, df_fee)
result = await provider._apply_fees(raw_price_amt_wh)
assert not result.isna().any()
# Covered timestamps: fee applied.
expected_covered = (0.0001 + 0.000288) * (100.0 + 19.0) / 100.0
assert abs(result.iloc[0] - expected_covered) < 1e-9
assert abs(result.iloc[1] - expected_covered) < 1e-9
# Uncovered timestamps: fee treated as zero, so the raw price passes through
# (raw + 0) * (100 + 0) / 100 == raw.
assert abs(result.iloc[2] - 0.0001) < 1e-9
assert abs(result.iloc[3] - 0.0001) < 1e-9
class TestPricePredictionProviderBaseStoreGrossSeries:
"""Tests for PricePredictionProviderBase._store_gross_series() wiring.
`key_to_raw_series`, `_apply_fees`, and `key_from_series` are mocked/spied
individually so these tests check the *wiring* - the right keys and bounds
flow through, in the right order - rather than the fee math (already
covered by TestPricePredictionProviderBaseApplyFees) or requiring a real
fee provider to be registered.
"""
@pytest.mark.asyncio
async def test_store_gross_series_uses_raw_and_gross_key_hooks(self, provider, monkeypatch):
"""Test that the raw series is read from `_raw_key` and the result is
written to `_gross_key`, both sourced from the subclass hooks rather
than hardcoded."""
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
end_dt = start_dt.add(hours=1)
idx = pd.DatetimeIndex([start_dt.add(minutes=15 * i) for i in range(4)])
raw_series = pd.Series([0.0001, 0.0002, 0.0003, 0.0004], index=idx)
gross_series = raw_series * 1.19 # arbitrary stand-in for the fee-applied result
mock_key_to_raw_series = AsyncMock(return_value=raw_series)
mock_apply_fees = AsyncMock(return_value=gross_series)
mock_key_from_series = AsyncMock()
# Patch on the class, not the instance: these are real methods, not
# declared pydantic fields, and the model has validate_assignment
# enabled, so instance-level setattr is rejected (see
# _patch_keys_to_dataframe's docstring for the same issue).
monkeypatch.setattr(type(provider), "key_to_raw_series", mock_key_to_raw_series)
monkeypatch.setattr(type(provider), "_apply_fees", mock_apply_fees)
monkeypatch.setattr(type(provider), "key_from_series", mock_key_from_series)
await provider._store_gross_series(start_datetime=start_dt, end_datetime=end_dt)
mock_key_to_raw_series.assert_awaited_once_with(
key="test_price_raw_wh", start_datetime=start_dt, end_datetime=end_dt
)
mock_apply_fees.assert_awaited_once()
assert mock_apply_fees.await_args
(apply_fees_arg,) = mock_apply_fees.await_args.args
assert apply_fees_arg is raw_series
mock_key_from_series.assert_awaited_once_with("test_price_wh", gross_series)
@pytest.mark.asyncio
async def test_store_gross_series_without_bounds_defaults_to_none(self, provider, monkeypatch):
"""Test that omitting start_datetime/end_datetime forwards None, not an
implicit "full history" value computed here - bound selection is the
caller's responsibility, per `_store_gross_series`'s docstring."""
idx = pd.DatetimeIndex(
[to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")]
)
raw_series = pd.Series([0.0001], index=idx)
mock_key_to_raw_series = AsyncMock(return_value=raw_series)
mock_apply_fees = AsyncMock(return_value=raw_series)
mock_key_from_series = AsyncMock()
monkeypatch.setattr(type(provider), "key_to_raw_series", mock_key_to_raw_series)
monkeypatch.setattr(type(provider), "_apply_fees", mock_apply_fees)
monkeypatch.setattr(type(provider), "key_from_series", mock_key_from_series)
await provider._store_gross_series()
mock_key_to_raw_series.assert_awaited_once_with(
key="test_price_raw_wh", start_datetime=None, end_datetime=None
)
+1
View File
@@ -27,6 +27,7 @@ def provider(sample_import_1_json, config_eos):
}
config_eos.merge_settings_from_dict(settings)
provider = PVForecastImport()
provider._db_reset_state()
assert provider.enabled()
return provider
+4 -1
View File
@@ -33,7 +33,10 @@ def provider(config_eos):
},
}
config_eos.merge_settings_from_dict(settings)
return WeatherClearOutside()
provider = WeatherClearOutside()
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
+1
View File
@@ -27,6 +27,7 @@ def provider(sample_import_1_json, config_eos):
}
config_eos.merge_settings_from_dict(settings)
provider = WeatherImport()
provider._db_reset_state()
assert provider.enabled() == True
return provider
+1
View File
@@ -6,6 +6,7 @@
../_generated/configcache.md
../_generated/configdatabase.md
../_generated/configdevices.md
../_generated/configelecfee.md
../_generated/configelecprice.md
../_generated/configems.md
../_generated/configfeedintariff.md
+296
View File
@@ -0,0 +1,296 @@
## Electricity Price Prediction Configuration
<!-- pyml disable line-length -->
:::{table} elecfee
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| elecfeefixed | `EOS_ELECFEE__ELECFEEFIXED` | `ElecFeeFixedCommonSettings` | `rw` | `required` | Fixed electricity fees provider settings. |
| elecfeeimport | `EOS_ELECFEE__ELECFEEIMPORT` | `ElecFeeImportCommonSettings` | `rw` | `required` | Electricity fees import provider settings. |
| provider | `EOS_ELECFEE__PROVIDER` | `str | None` | `rw` | `None` | Electricity fee provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity fee provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
},
"providers": [
"ElecFeeFixed",
"ElecFeeImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecfee data import from file or JSON String
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeeimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `str | pathlib.Path | None` | `rw` | `None` | Path to the file to import elecfee data from. |
| import_json | `str | None` | `rw` | `None` | JSON string, dictionary of electricity fee forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeeimport": {
"import_file_path": null,
"import_json": "{\"elecfee_consumption_amt_wh\": [0.0003384, 0.0003318, 0.0003284]}"
}
}
}
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed::consumption_amt_kwh
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for fixed electricity fees
This model defines a fixed electricity fee schedule using a sequence
of time windows. Each window specifies a time interval and the electricity
fee applicable during that interval.
<!-- pyml disable line-length -->
:::{table} elecfee::elecfeefixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed per-kWh electricty fee charged for consumed energy, accumulating all applicable fixed per-kWh charges (e.g. network charge, metering fee, concession fee) into a single amount [amount/kWh]. If not provided, no fixed per-kWh consumption fee is applied. |
| consumption_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total fixed electricity surcharge applied as a percentage of the monetary amount already charged for consumed energy, accumulating all applicable percentage-based surcharges (e.g. VAT, electricity tax) into a single percentage [%]. This is a percentage of the fee amount, not a per-kWh rate. If not provided, no percentage-based consumption surcharge is applied. |
| feedin_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total deduction from feed-in energy per Wh [amount/Wh]. This is the accumulation of all fixed per-Wh charges deducted from feed-in energy - such as metering fees or grid-operator handling charges - into a single amount. Applied after the percentage-based deduction, i.e. it reduces the price by a flat amount per Wh rather than by a share of the raw price. If not provided, no fixed per-kWh feed-in fee is applied. |
| feedin_percent_amt | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the total percentage deducted from the raw feed-in price (spot price) [%]. This is the accumulation of all percentage-based deductions payable on the feed-in tariff - such as a marketing or balancing fee retained by the aggregator - into a single percentage. It is applied as `raw_price * (100 - percent) / 100`, i.e. it scales down the raw price rather than adding a surcharge to it. If not provided, no percentage-based feed-in deduction is applied. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecfee": {
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"consumption_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
},
"feedin_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.00288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.0034
}
]
},
"feedin_percent_amt": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "1 day",
"day_of_week": null,
"date": null,
"locale": null,
"value": 19.0
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
+74 -93
View File
@@ -7,14 +7,14 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `float | None` | `rw` | `None` | Electricity price charges [amount/kWh]. Will be added to variable market price. |
| akkudoktor | `EOS_ELECPRICE__AKKUDOKTOR` | `ElecPriceAkkudoktorCommonSettings` | `rw` | `required` | Akkudoktor electricity price provider settings. |
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Electricity price import provider settings. |
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `str | None` | `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` | SMARD electricity price provider settings. |
| tibber | `EOS_ELECPRICE__TIBBER` | `ElecPriceTibberCommonSettings` | `rw` | `required` | Tibber electricity price provider settings. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `float | None` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
@@ -27,10 +27,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -41,6 +40,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -59,10 +62,9 @@
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -73,6 +75,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -82,6 +88,7 @@
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceImport",
"ElecPriceSMARD",
"ElecPriceTibber"
]
}
@@ -120,6 +127,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 -->
@@ -180,89 +218,6 @@
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `pydantic_extra_types.pendulum_dt.Date | None` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `int | str | None` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `str | None` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `float | None` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- 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": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- 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": {
"elecpricefixed": {
"time_windows": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common configuration settings for fixed electricity pricing
This model defines a fixed electricity price schedule using a sequence
@@ -276,7 +231,7 @@ price applicable during that interval.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| time_windows | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
| elecprice_marketprice_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
:::
<!-- pyml enable line-length -->
@@ -289,7 +244,7 @@ price applicable during that interval.
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
@@ -314,3 +269,29 @@ price applicable during that interval.
}
```
<!-- pyml enable line-length -->
### Common configuration settings for Akkodoktor electricity pricing
<!-- pyml disable line-length -->
:::{table} elecprice::akkudoktor
: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
{
"elecprice": {
"akkudoktor": {}
}
}
```
<!-- pyml enable line-length -->
+32 -5
View File
@@ -95,12 +95,32 @@
"home_appliances": [],
"max_home_appliances": 1
},
"elecfee": {
"provider": "ElecFeeFixed",
"elecfeefixed": {
"consumption_amt_kwh": {
"windows": []
},
"consumption_percent_amt": {
"windows": []
},
"feedin_amt_kwh": {
"windows": []
},
"feedin_percent_amt": {
"windows": []
}
},
"elecfeeimport": {
"import_file_path": null,
"import_json": null
}
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"akkudoktor": {},
"elecpricefixed": {
"time_windows": {
"elecprice_marketprice_amt_kwh": {
"windows": []
}
},
@@ -111,6 +131,10 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {
"filter_id": 4169,
"region": "DE"
},
"tibber": {
"access_token": null,
"home_id": null
@@ -124,7 +148,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -136,7 +162,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
},
"general": {
"config_save_mode": "AUTOMATIC",
+58 -5
View File
@@ -13,6 +13,7 @@
| feedintariffimport | `EOS_FEEDINTARIFF__FEEDINTARIFFIMPORT` | `FeedInTariffImportCommonSettings` | `rw` | `required` | Feed in tarif import provider settings. |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `str | None` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
| smard | `EOS_FEEDINTARIFF__SMARD` | `FeedInTariffSMARDCommonSettings` | `rw` | `required` | SMARD feed in tariff provider settings. |
:::
<!-- pyml enable line-length -->
@@ -26,7 +27,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -38,7 +41,8 @@
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"smard": {}
}
}
```
@@ -54,7 +58,9 @@
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_kwh": null
"feed_in_tariff_amt_kwh": {
"windows": []
}
},
"feedintariffimport": {
"import_file_path": null,
@@ -67,12 +73,14 @@
"energycharts": {
"bidding_zone": "DE-LU"
},
"smard": {},
"providers": [
"FeedInTariffAkkudoktor",
"FeedInTariffDvhubOnline",
"FeedInTariffEnergyCharts",
"FeedInTariffFixed",
"FeedInTariffImport",
"FeedInTariffSMARD",
"FeedInTariffTibber"
]
}
@@ -80,6 +88,32 @@
```
<!-- pyml enable line-length -->
### Settings for SMARD feed-in prices shared with ``elecprice.smard``
<!-- pyml disable line-length -->
:::{table} feedintariff::smard
: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": {
"smard": {}
}
}
```
<!-- pyml enable line-length -->
### Common settings for feed in tariff data import from file or JSON string
<!-- pyml disable line-length -->
@@ -120,7 +154,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| feed_in_tariff_kwh | `float | None` | `rw` | `None` | Electricity price feed in tariff [amount/kWh]. |
| feed_in_tariff_amt_kwh | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the electricity feed in tariff [amount/kWh]. If not provided, no fixed feed in tariff is applied. |
:::
<!-- pyml enable line-length -->
@@ -133,7 +167,26 @@
{
"feedintariff": {
"feedintarifffixed": {
"feed_in_tariff_kwh": 0.078
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.028
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.034
}
]
}
}
}
}
+1 -2
View File
@@ -43,8 +43,7 @@
}
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21
"provider": "ElecPriceAkkudoktor"
},
"load": {
"loadakkudoktor": {
+1 -2
View File
@@ -11,8 +11,7 @@
}
},
"elecprice": {
"provider": "ElecPriceImport",
"charges_kwh": 0.21
"provider": "ElecPriceImport"
},
"server": {
"host": "0.0.0.0",
Generated
+293 -263
View File
@@ -3,13 +3,13 @@ revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'emscripten'",
"python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'emscripten'",
"python_full_version == '3.14.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
@@ -529,149 +529,149 @@ wheels = [
[[package]]
name = "charset-normalizer"
version = "3.5.0"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/42/71e4e3bfe59202feef062c68487f54c6adf501cfbe087ecd93e3cd597fea/charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92", size = 349110, upload-time = "2026-08-12T14:32:07.832Z" },
{ url = "https://files.pythonhosted.org/packages/0f/dd/fd3386d0fbd358d3b5c7a2fa5bf312afe6159b04fafeb67d39fa971d7448/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add", size = 250773, upload-time = "2026-08-12T14:32:09.113Z" },
{ url = "https://files.pythonhosted.org/packages/17/ad/4901a66d6d3b17f1096725d7e50266132c16555aa6a70047fe1cf262b4b2/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39", size = 240229, upload-time = "2026-08-12T14:32:10.43Z" },
{ url = "https://files.pythonhosted.org/packages/43/b7/1d790e0425e0f4c99e9b89a3956a94a6d2d0c6f01b2a5eca93d8f082d5ac/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d", size = 280757, upload-time = "2026-08-12T14:32:11.628Z" },
{ url = "https://files.pythonhosted.org/packages/44/bb/4b8c8086c67636e52d6354ad17697f54a00b40041ca53dc765737e21709b/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d", size = 276174, upload-time = "2026-08-12T14:32:12.808Z" },
{ url = "https://files.pythonhosted.org/packages/25/fa/690a11924c40c766d258d2b74d817cc5efe7bbcfceeedc5c0f35256d7524/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b", size = 261808, upload-time = "2026-08-12T14:32:14.138Z" },
{ url = "https://files.pythonhosted.org/packages/53/2d/4a6945eff0c8f684e3f5b7b978644ab18ba9198da060dbaa1d9206bc6cc9/charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8", size = 259922, upload-time = "2026-08-12T14:32:15.485Z" },
{ url = "https://files.pythonhosted.org/packages/60/4d/10ac7e07bbf7ea569effeb9524e32f345f7e643800b20d538fb4706eab4e/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79", size = 252315, upload-time = "2026-08-12T14:32:16.764Z" },
{ url = "https://files.pythonhosted.org/packages/13/bf/1ddacf7aa7da12097f229a6a4e71a70200937a621b3617f6dc819fb99a66/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224", size = 240600, upload-time = "2026-08-12T14:32:17.956Z" },
{ url = "https://files.pythonhosted.org/packages/c2/88/293444d48c8d86fe51552262117134a9fc666d68cbbbc9b8c1b2b35a29be/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c", size = 280788, upload-time = "2026-08-12T14:32:19.1Z" },
{ url = "https://files.pythonhosted.org/packages/12/ed/0e34e40584f51eda38d4a5daf25fd8586366347efd4b2470dbf64710e778/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a", size = 258425, upload-time = "2026-08-12T14:32:20.207Z" },
{ url = "https://files.pythonhosted.org/packages/72/b2/c1b1c27f6f0ef35b21a8bdb592854bbf7f219629db3477f74c0b1380e0ed/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b", size = 277437, upload-time = "2026-08-12T14:32:21.345Z" },
{ url = "https://files.pythonhosted.org/packages/b2/bd/a1b7d959a37847675adb2f5978f81703306dd78df4fefb82ee5a1cc5e37f/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8", size = 262947, upload-time = "2026-08-12T14:32:22.613Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fd/a76a2d7e639bfc6a9c869371e34f28231eaca7d7126ec19895aa38eedb51/charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f", size = 181313, upload-time = "2026-08-12T14:32:23.736Z" },
{ url = "https://files.pythonhosted.org/packages/97/84/6fc03e802578df41a2ab9b6a1f26657fb92e32285a603ad5852a4a4f68c1/charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db", size = 206197, upload-time = "2026-08-12T14:32:24.938Z" },
{ url = "https://files.pythonhosted.org/packages/0a/27/7208360ff1901607359869fcc45ca0989d597f3e30777ba30c7254170587/charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b", size = 184925, upload-time = "2026-08-12T14:32:26.123Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" },
{ url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" },
{ url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" },
{ url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" },
{ url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" },
{ url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" },
{ url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" },
{ url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" },
{ url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" },
{ url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" },
{ url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" },
{ url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" },
{ url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" },
{ url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" },
{ url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" },
{ url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" },
{ url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" },
{ url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" },
{ url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" },
{ url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" },
{ url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" },
{ url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" },
{ url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" },
{ url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" },
{ url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" },
{ url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" },
{ url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" },
{ url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" },
{ url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" },
{ url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" },
{ url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" },
{ url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" },
{ url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" },
{ url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" },
{ url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" },
{ url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" },
{ url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" },
{ url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" },
{ url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" },
{ url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" },
{ url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" },
{ url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" },
{ url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" },
{ url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" },
{ url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" },
{ url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" },
{ url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" },
{ url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" },
{ url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" },
{ url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" },
{ url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" },
{ url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" },
{ url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" },
{ url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" },
{ url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" },
{ url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" },
{ url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" },
{ url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" },
{ url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" },
{ url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" },
{ url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" },
{ url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" },
{ url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" },
{ url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" },
{ url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" },
{ url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" },
{ url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" },
{ url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" },
{ url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
@@ -1051,11 +1051,11 @@ standard-no-fastapi-cloud-cli = [
[[package]]
name = "fastcore"
version = "2.2.12"
version = "2.2.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/42/88f579378b14dfdd682e81efc17eca8ec974fe2c7fbb2427b8f11735045e/fastcore-2.2.12.tar.gz", hash = "sha256:843589e613b1b6f69dc81fedb04075a0bcea159088509f560780aef6d3528294", size = 142585, upload-time = "2026-08-12T04:10:11.161Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/cb/9900ada9625e5f140ba972ea4ada7d57e9d4e2e8d90f742ef6453aec13a0/fastcore-2.2.13.tar.gz", hash = "sha256:e02695d7c1a508b4b85506a0bb02ade2e568b5ef61892fd95a817fd65812338a", size = 142887, upload-time = "2026-08-18T20:34:41.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/38/b039ab6965df48cb7feffb22fb2a1bfb9d20bc9ef7e46baee3c5a9e96dc6/fastcore-2.2.12-py3-none-any.whl", hash = "sha256:f926464bf4e03ea6bf4d6bb278b8d13e60c4cd52ddda277b6ef7646965f4cb37", size = 149154, upload-time = "2026-08-12T04:10:09.635Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/f620a74444c19e82717eecd8bf5508cc9bc2a65cb663cbf450d1373a40d3/fastcore-2.2.13-py3-none-any.whl", hash = "sha256:3ba463020a19a43a5c3cc88104f78084a3d209fa5e7f4c94e5ae2557609a7dcf", size = 149460, upload-time = "2026-08-18T20:34:39.315Z" },
]
[[package]]
@@ -1215,15 +1215,15 @@ wheels = [
[[package]]
name = "httpcore2"
version = "2.10.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
{ name = "h11", marker = "sys_platform != 'emscripten'" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" },
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
@@ -1286,7 +1286,7 @@ wheels = [
[[package]]
name = "httpx2"
version = "2.10.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
@@ -1296,9 +1296,9 @@ dependencies = [
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
]
[[package]]
@@ -1321,11 +1321,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.18"
version = "3.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
@@ -1648,104 +1648,134 @@ wheels = [
[[package]]
name = "lxml"
version = "6.1.1"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ad/a9/970b8fa0ecc4fbf1dfaed0d89bbc1fc1421b25ec26a2038c91e872dc6c8e/lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18", size = 4210626, upload-time = "2026-08-19T04:58:15.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
{ url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
{ url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" },
{ url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" },
{ url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" },
{ url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" },
{ url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" },
{ url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" },
{ url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" },
{ url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
{ url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" },
{ url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" },
{ url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" },
{ url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" },
{ url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2d/c292b75049d8b919a515a439646307b971a5f72cd99aaf77d59c9a99e7c4/lxml-6.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da6a4f55f0e3308c07354b1ee239c5550afc212f81629a6067db505ace3b667a", size = 8563059, upload-time = "2026-08-19T04:58:26.559Z" },
{ url = "https://files.pythonhosted.org/packages/69/55/16395f232cb28182c72a1fb4d9d187163fd05a581a98c37f33e945b77a6d/lxml-6.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f4d2c36fd5997d30ff19c29fb93293401d0daaf87512297d47610e6883964b5", size = 4613599, upload-time = "2026-08-19T04:58:30.589Z" },
{ url = "https://files.pythonhosted.org/packages/08/20/a65a084596ccd7fd1ed0668b4cf3b68e700da4eac830a0f22ac569f19a73/lxml-6.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1d55a614d2f0457b1f7511c1b7bec0db0dcdd4af4d09d226829eb054c647527c", size = 4935619, upload-time = "2026-08-19T04:58:45.181Z" },
{ url = "https://files.pythonhosted.org/packages/e1/35/008bf5a5f8809a90a3e62909d8d4458f09b7c034c365b508990bdc38b5b7/lxml-6.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:575fef7f30048b744dffb3e4ff64a18cac7dba3fd26efdea5730ade9d1bdeb33", size = 5078913, upload-time = "2026-08-19T04:58:53.376Z" },
{ url = "https://files.pythonhosted.org/packages/4f/cf/041b4c15ba3b0421ed828af60993f23cf6e5ea8801efb773b19e248fc6a5/lxml-6.1.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79b428c3242e63bdacf3b526a34e0b8b26583846fc597da84b8f0c3d5ea446b2", size = 5012236, upload-time = "2026-08-19T04:59:06.663Z" },
{ url = "https://files.pythonhosted.org/packages/06/42/89a2760cd2f2cda28ef5b9591ec775a6a5183d193e7b62ddb936b1565167/lxml-6.1.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12ecfea07d767f6accbf30b014e1c477b5eabb13eb4e8c748215efb52c0e314a", size = 5211283, upload-time = "2026-08-19T04:59:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/5d/0d/f5607ff466d0d8874d7b778c3ccb64f65ccc0ac430e1961969fd450b899c/lxml-6.1.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bfcbee8ffff4188f4c6d97eceeff36d8eb983cf838933cbc12ce5f5dd51476c6", size = 5343352, upload-time = "2026-08-19T04:59:41.056Z" },
{ url = "https://files.pythonhosted.org/packages/63/6a/77713b73265d043a513d9e7df2458f07b2a14709f95e3a35a34834785fde/lxml-6.1.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:822d9397033edbe530a13bb1e0091c0e817536b6aba87a9b4ad626ed779ca0bd", size = 4673191, upload-time = "2026-08-19T05:00:01.85Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c7/e4179e0b9f71859bf9a56b3da91db4c7e85c47072018e7b63e019ff65c9f/lxml-6.1.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4303f904fb6c41b58dc70743b1d8a470aba6c9897427c48324cff1a95673ddb4", size = 5281079, upload-time = "2026-08-19T05:00:20.59Z" },
{ url = "https://files.pythonhosted.org/packages/22/f4/358200b95081db4fd02c4d81938a07080ae7636f9149befda1c0e5189c40/lxml-6.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdd35422de747237f451e821766e2b6be3dd2c31955c1ecd7f17984c5b9bb62d", size = 5055515, upload-time = "2026-08-19T05:00:29.28Z" },
{ url = "https://files.pythonhosted.org/packages/fe/06/8fe708d90022bd13122c359d38f3f751e4fa71b871eace7fa81212dadfa5/lxml-6.1.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b3ca02ef3b5920b88119c82eb6badfb2d082b1f681d528a856dcce17c8706da8", size = 4722745, upload-time = "2026-08-19T05:00:49.132Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1d/9d374182c2ee79a5097d4950bfca9e28011eeacdf614db022b9905266b5c/lxml-6.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4bf14db2f0214003ec7f46c4300e2065668fc93e20448c1c95bac2e952072168", size = 5268962, upload-time = "2026-08-19T05:01:15.762Z" },
{ url = "https://files.pythonhosted.org/packages/72/89/d0835e464b84d92c43d838bbeaef02f9ac374ab2bb6972411e4c3e80975d/lxml-6.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2afd1688e372d8eafaa6f56c589399e0a87d086a0c110f6346b0b50f42e67e25", size = 5235564, upload-time = "2026-08-19T05:03:11.298Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ea/0b8acc86d702b9fa1a0194fc7e653087912d340cb10507f4a5bc369d04b3/lxml-6.1.2-cp311-cp311-win32.whl", hash = "sha256:aea814342f6afd20d832937ff8b333cd6506428a39c0c4c70c2380aab1887bfb", size = 3600342, upload-time = "2026-08-19T05:03:14.238Z" },
{ url = "https://files.pythonhosted.org/packages/65/5c/04480497142794bfb2d98c01ea9972e9b3d0f6b1f017073cabb74ab0b8c1/lxml-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:b3db5497af55f7a557c95265dd3b91c75dc56364a7b59f258c45fa5576dce058", size = 4032771, upload-time = "2026-08-19T05:03:16.934Z" },
{ url = "https://files.pythonhosted.org/packages/f9/88/4c5ca0f808a80b7eaad073269f1fc53992c5c7c905df13d3953d886834b1/lxml-6.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:e8dc3d29f2ed2bbf24c205a86326d6681230ace55abfb3f9d5230f42078ad63d", size = 3674380, upload-time = "2026-08-19T05:03:19.158Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/55eb54507073089ab27743c5da2113c84f0d0b1715b33175fdd943c9652d/lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237", size = 8602111, upload-time = "2026-08-19T04:58:28.017Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bf/6332f45d78da385bb01d5cac3fe4acda19f025d1307cbc7ad538355fecbb/lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3", size = 4638376, upload-time = "2026-08-19T04:58:41.181Z" },
{ url = "https://files.pythonhosted.org/packages/68/e0/21fba0fe74d417fbe976903ae6bc77e92cdce01aae7b636abd87756f4588/lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40", size = 4939689, upload-time = "2026-08-19T04:58:48.526Z" },
{ url = "https://files.pythonhosted.org/packages/de/e5/ce3e885264fdd0bdcb6b49c1ea1842f94281b39e4ff956099e8d57532c60/lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd", size = 5105185, upload-time = "2026-08-19T04:59:15.533Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b6/990a8446c488c70fa25681e150de94b7bf2eaaf387e374d195ab3c8faafb/lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99", size = 5011863, upload-time = "2026-08-19T04:59:50.58Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6a/f70f41363dae27e3bfd6224b128f5ba150874bd32ca4938552930ffa33b0/lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9", size = 5638234, upload-time = "2026-08-19T05:00:00.802Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/a65b64f34d556925faef2c4f14167d58c571bc15a3e1f2bba71138830562/lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960", size = 5244532, upload-time = "2026-08-19T05:00:07.516Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a9/471552e015e954fc9d960aa27c3d67ebf489683d03f033399a790417c67c/lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5", size = 5358194, upload-time = "2026-08-19T05:00:22.747Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0f/bc6248fbec2cc416f102b1267f1567e07510f6fa909bbe8cd2a22d6fb78e/lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185", size = 4704432, upload-time = "2026-08-19T05:00:51.115Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3f/cec859f50e63f1fa338fab43d2362d7543e1237f2475960d8ab0769de0eb/lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9", size = 5255038, upload-time = "2026-08-19T05:00:58.895Z" },
{ url = "https://files.pythonhosted.org/packages/7c/d9/2ced0cf2967115f92a1b8b3ae6bd18763abc3ebef88c98cf25145fda396c/lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003", size = 5054481, upload-time = "2026-08-19T05:01:10.096Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f5/4f07386d3c88673daeec3b8cc09a2a4d39fa01c1fc49009791b0746d97fa/lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42", size = 4785535, upload-time = "2026-08-19T05:01:18.909Z" },
{ url = "https://files.pythonhosted.org/packages/9a/5a/f4fe3ecbc189f48fba2547c5db5c940a10151d3e86b856a60a533a77e816/lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70", size = 5655337, upload-time = "2026-08-19T05:01:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/f586aa1bf27bfbace2dfdbb704da5c52f0bdece8ee440c8fb4946c940b2e/lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f", size = 5245778, upload-time = "2026-08-19T05:01:45.227Z" },
{ url = "https://files.pythonhosted.org/packages/18/a1/677494bbaef4d6db5e4633af817414f478865850b55c03ae4bf70fa7b8ca/lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313", size = 5267274, upload-time = "2026-08-19T05:01:57.687Z" },
{ url = "https://files.pythonhosted.org/packages/5a/71/b71425b8764d4cb7c92eb970483be7d5610dce2a6316242b5aaae7d260be/lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3", size = 3602563, upload-time = "2026-08-19T05:02:01.837Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fb/909584e16d2148c1a252cc2c32dd99fe0e2682459c586d3d7a192e74a0ae/lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f", size = 4005965, upload-time = "2026-08-19T05:02:07.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8d/41207c9212caad0b52749e34739fb9bfab67486729f52a8fe9bd9266fee6/lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49", size = 3666641, upload-time = "2026-08-19T05:02:11.3Z" },
{ url = "https://files.pythonhosted.org/packages/61/2a/e9651f47a31a60b5cae031abc23391ed9aa30c8fc07571d1a38f58d6d770/lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4", size = 8590165, upload-time = "2026-08-19T04:58:40.489Z" },
{ url = "https://files.pythonhosted.org/packages/61/87/a8098abaf35118767d1703b84c98940a5d833064e0eca39a00ecfe9840ab/lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0", size = 4632474, upload-time = "2026-08-19T04:58:47.465Z" },
{ url = "https://files.pythonhosted.org/packages/93/cc/fe74d1def7f4fb967c4a825608a074d4dbdbb871b0d6bd59c6ed07d67868/lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c", size = 4936196, upload-time = "2026-08-19T04:59:03.477Z" },
{ url = "https://files.pythonhosted.org/packages/b7/ad/b96e6ca926e26726a99aa643602aac7411ecc1731ddb1b25af8cc57edfcd/lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a", size = 5093290, upload-time = "2026-08-19T04:59:17.498Z" },
{ url = "https://files.pythonhosted.org/packages/d2/84/616f5d3b7cd086fcfba3e5add6fccda67f976c1c753ae9ed7bbd317cb9be/lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871", size = 4998767, upload-time = "2026-08-19T04:59:28.385Z" },
{ url = "https://files.pythonhosted.org/packages/80/88/d5b453a8d083483c9442ad7f5ac5c560796022eb5c80d60b65d75e449236/lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9", size = 5626717, upload-time = "2026-08-19T04:59:40.045Z" },
{ url = "https://files.pythonhosted.org/packages/71/45/31e5aa4d4bae024908ba1d03480c7425cf027a28b7e5c88d1b7202bd80cc/lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f", size = 5232330, upload-time = "2026-08-19T04:59:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5a/2627912420df8b2d31ba3014da5539f15ec85add01d42048864ffefda516/lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372", size = 5347054, upload-time = "2026-08-19T04:59:52.762Z" },
{ url = "https://files.pythonhosted.org/packages/16/86/54ac0f529b22a8f12313726dd49e12961bb46471d9028cc28d2a29408f0b/lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c", size = 4707275, upload-time = "2026-08-19T05:00:04.836Z" },
{ url = "https://files.pythonhosted.org/packages/3a/42/ffcdc6e4519be90df907cdae7e88409efb25d823ae4de8846f737dae1884/lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b", size = 5240071, upload-time = "2026-08-19T05:00:19.604Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/5b1d7ab35f013f1127ec48f3108319f58b65b00d5cb26f215adbe86eadfb/lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a", size = 5050356, upload-time = "2026-08-19T05:00:27.968Z" },
{ url = "https://files.pythonhosted.org/packages/b0/57/1cf049d054189b55c8fe8012269234f6602256949b69cd3ba80608a88219/lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa", size = 4780394, upload-time = "2026-08-19T05:00:39.047Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ad/064488a8fa60e639fd773e421a18bf17541d02a95fbf36238ad7c65f69d4/lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1", size = 5645854, upload-time = "2026-08-19T05:03:42.697Z" },
{ url = "https://files.pythonhosted.org/packages/85/bb/120e56f3cf1c149bb3b014278fb86d0a6dd552403981081f0ee0a0a57be7/lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20", size = 5231132, upload-time = "2026-08-19T05:03:45.466Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2c/7d49aab893c128671a3276580074cce4c002896145b8dd2893da79633bca/lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881", size = 5256076, upload-time = "2026-08-19T05:03:48.092Z" },
{ url = "https://files.pythonhosted.org/packages/72/28/ddea3aa1fa9acfd384fe34d4a2a93eecc07541dd2d922fa9b140c60d8014/lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c", size = 3602177, upload-time = "2026-08-19T05:03:50.571Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7a/96bac167538748cae2544335855f812fa33e49a9a67bc8b8520dcbd592bd/lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5", size = 4004117, upload-time = "2026-08-19T05:03:53.074Z" },
{ url = "https://files.pythonhosted.org/packages/0a/24/9498fa3c84135956e5ef55ea4d8bd11e999e381f7f210fb6f8c6a980ef03/lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73", size = 3665412, upload-time = "2026-08-19T05:03:55.621Z" },
{ url = "https://files.pythonhosted.org/packages/27/b5/728b0578791b397ace8d1b101c8b3fe10f36043542f7bb85f82d8bdc3f50/lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29", size = 8609651, upload-time = "2026-08-19T04:58:42.42Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6b/49209fa6225c15c48a30061f03d3aba75e3c19634813b88bf83b88c525ed/lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b", size = 4639588, upload-time = "2026-08-19T04:59:01.501Z" },
{ url = "https://files.pythonhosted.org/packages/20/86/80bae4e8bc2eed9d6f017701a3d86fdea56936218efa738911d0b76aa7f4/lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324", size = 4964846, upload-time = "2026-08-19T04:59:08.59Z" },
{ url = "https://files.pythonhosted.org/packages/70/ce/4782caee7a22959c1ac67cb46495e03912c22a4ba7d20c163496a519e815/lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd", size = 5099288, upload-time = "2026-08-19T04:59:18.649Z" },
{ url = "https://files.pythonhosted.org/packages/32/21/f120967cc43b54e05512dff0c39726b832c836195d30f41f88733ef36ac8/lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a", size = 5036837, upload-time = "2026-08-19T04:59:47.217Z" },
{ url = "https://files.pythonhosted.org/packages/61/ba/8005e9f47598e3ec5c18312c77f94e889580027616678848405c6aeba5de/lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03", size = 5658569, upload-time = "2026-08-19T04:59:54.078Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ba/add33b3c7ce51462cf7a4637bcfec2eaa258364d6015b989dd7d1216e6a6/lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999", size = 5246003, upload-time = "2026-08-19T04:59:59.764Z" },
{ url = "https://files.pythonhosted.org/packages/05/b3/a43012748fb861c914c5eac1c1a3bad44282e767499cd02280d4d1edf092/lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645", size = 5354047, upload-time = "2026-08-19T05:00:21.657Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cb/813021d9a445713b8d758b9e5eae2ed392cd598d9f119d9b053b37c2ab93/lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6", size = 4704382, upload-time = "2026-08-19T05:00:47.067Z" },
{ url = "https://files.pythonhosted.org/packages/17/c9/1155299f4577bebf3c280497534a73e4b8ad8cab3b96074731ad10949d4e/lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935", size = 5258530, upload-time = "2026-08-19T05:01:14.893Z" },
{ url = "https://files.pythonhosted.org/packages/25/6e/d76e58384b378b877e140e25b9a9835da00035f81ff70cbe943a3749bf27/lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b", size = 5089919, upload-time = "2026-08-19T05:01:33.602Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b7/898013c0f8891481d0624ab3bd5dd8c8ff827232dfee2a5d1f8bf970a7cc/lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a", size = 4741972, upload-time = "2026-08-19T05:01:38.18Z" },
{ url = "https://files.pythonhosted.org/packages/c3/47/efb53c4d7b655831c03317a450d9da439b0829c61f34d9d4fe7c863445d6/lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a", size = 5683241, upload-time = "2026-08-19T05:02:00.731Z" },
{ url = "https://files.pythonhosted.org/packages/da/0a/0ff36a584cbba14a71326ee8a5300694400f0b97927d1f90a87d95b17d4a/lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68", size = 5245892, upload-time = "2026-08-19T05:02:06.108Z" },
{ url = "https://files.pythonhosted.org/packages/8f/9e/303717a1aa56d4bd775c91936717d3c9e8d999a8e8b68b00979c4c1f93d0/lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b", size = 5269528, upload-time = "2026-08-19T05:02:09.883Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c2/2ae7cb97089eb86bf0689516db3cf280a007b6145853d2a0235a1f01683d/lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605", size = 3662743, upload-time = "2026-08-19T05:03:02.513Z" },
{ url = "https://files.pythonhosted.org/packages/77/13/a3d483230a09201e211ceb1aa208b1374d27d23b8b180d74dba14b30f6b3/lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d", size = 4073942, upload-time = "2026-08-19T05:03:04.864Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f1/c1445d4b6ad7c51e39d4e2ebbf015a4880f5b297a4ab0e77e4d0e5b70110/lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63", size = 3749235, upload-time = "2026-08-19T05:03:07.239Z" },
{ url = "https://files.pythonhosted.org/packages/9d/eb/598c76f4ce19a67c635e86a46d880cc854f308f39a6f1fdf13bbb01813ec/lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222", size = 8860315, upload-time = "2026-08-19T05:02:14.39Z" },
{ url = "https://files.pythonhosted.org/packages/da/c7/1f9fac7b566a86ad0da13dcc0259164266469c0ad86744c740ccd5c2a081/lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd", size = 4755176, upload-time = "2026-08-19T05:02:18.705Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1b/cfda9307388d496e7eeb7493d9455896b8137ed95f51f3d6ae6ddcc14a47/lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e", size = 4979444, upload-time = "2026-08-19T05:02:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/e5/71/f732c8919c45b7f29acf443288c6e90036877a67bfeeb1acceb0fffa011b/lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923", size = 5115887, upload-time = "2026-08-19T05:02:23.62Z" },
{ url = "https://files.pythonhosted.org/packages/30/00/121d52b944f41e33ea86c62875f902d24982842dc7231ab154ac5a6c6593/lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595", size = 5032418, upload-time = "2026-08-19T05:02:26.114Z" },
{ url = "https://files.pythonhosted.org/packages/70/19/cadb73c7fe48c7563dc8ab62ea53d5b920c8911bfb808507a6daa82e78d2/lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e", size = 5603304, upload-time = "2026-08-19T05:02:28.694Z" },
{ url = "https://files.pythonhosted.org/packages/13/32/9de126a14d5a5db8c371c5ec869178417db226707b62a47273a95ae6df7f/lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88", size = 5228938, upload-time = "2026-08-19T05:02:30.99Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9b/22dd9e843629ed04652591fb220eb2bf2394d97be3be377d60d8083405d7/lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118", size = 5317790, upload-time = "2026-08-19T05:02:33.301Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2c/b12a1dc121f81c280635c721c7bcaa341441fcbe37397f60b8915048aece/lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68", size = 4646468, upload-time = "2026-08-19T05:02:35.504Z" },
{ url = "https://files.pythonhosted.org/packages/57/41/fd87a41edc531e7969c25ab1d6b52b5b041eb108b88f6394d6afb4374396/lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852", size = 5240607, upload-time = "2026-08-19T05:02:37.805Z" },
{ url = "https://files.pythonhosted.org/packages/6e/30/713ba813b6e6673c6dc34733746516017efcd17949b767b154cc50bccf20/lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a", size = 5086495, upload-time = "2026-08-19T05:02:40.099Z" },
{ url = "https://files.pythonhosted.org/packages/33/f8/6532ce0fecd9c326d06b08274ee075cc28dbc9f5e9285355db8504689114/lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b", size = 4758801, upload-time = "2026-08-19T05:02:45.434Z" },
{ url = "https://files.pythonhosted.org/packages/74/b6/5a1f7833ebaa0dd33c28f6f9755ec6ff3891bf63f097634b44e6da1bb65e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1", size = 5626977, upload-time = "2026-08-19T05:02:48.092Z" },
{ url = "https://files.pythonhosted.org/packages/e6/20/6ae0fc1b45e20877cdcfb1168ceeaf9abb0fba5ed36bd639a260e7b2101e/lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d", size = 5235036, upload-time = "2026-08-19T05:02:50.726Z" },
{ url = "https://files.pythonhosted.org/packages/47/b4/2bc7b37fbb990ccfb7d30393660741592177224a94e07d842c8da70638e8/lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45", size = 5252270, upload-time = "2026-08-19T05:02:53.454Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0b/07fb8e1dee29a78e2c5fa5c6c914218be76a6406baff27907429566e90ec/lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b", size = 3902666, upload-time = "2026-08-19T05:02:55.607Z" },
{ url = "https://files.pythonhosted.org/packages/58/ab/3371527bd9820aae6f511697c93032ed197b0d8dab0f17818f18d3099637/lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c", size = 4401011, upload-time = "2026-08-19T05:02:57.899Z" },
{ url = "https://files.pythonhosted.org/packages/e6/bb/e6de9b2546a4e6df4fb52fb18921906a8b7a041aba06570995759a4d6d8b/lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d", size = 3823384, upload-time = "2026-08-19T05:03:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/0e/83/7ff98683e14a148191278728d11ba782c3d5137886d49fd95ab4036efa1b/lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e", size = 8609183, upload-time = "2026-08-19T04:58:32.19Z" },
{ url = "https://files.pythonhosted.org/packages/24/39/c39f05e8240e98009dd3d4ceb248319d0f36467babc5f90a909ed0c5b68a/lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a", size = 4639898, upload-time = "2026-08-19T04:58:39.017Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bf/25e26b089510940a0777ab334357874569255e50930224c8159cd649e754/lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef", size = 5037527, upload-time = "2026-08-19T04:58:46.224Z" },
{ url = "https://files.pythonhosted.org/packages/65/6d/aed3a58a3d662f7367a537fabe8c549f1446dbd043719e0ae8cd53f47819/lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19", size = 5661918, upload-time = "2026-08-19T04:59:02.534Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ca/706d32b6957c0c2e005a9833e8fc528449196b38d5cfcf9e0fd86a96fb00/lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f", size = 5249359, upload-time = "2026-08-19T05:04:01.956Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e9/445ff43f56fcffa06f6f3a7189920c216f3eacef68ef834d4111cdbd86ba/lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79", size = 4704548, upload-time = "2026-08-19T05:04:04.57Z" },
{ url = "https://files.pythonhosted.org/packages/69/78/20b8b7e79a1b1d9cd4465c332d62962858562b446692f16a27068fa54b85/lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537", size = 5261170, upload-time = "2026-08-19T05:04:07.336Z" },
{ url = "https://files.pythonhosted.org/packages/54/ca/84a0e1148bf511e12e0d99732a4e136a3bf1b91622f0a1b197796e2ff984/lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453", size = 5090576, upload-time = "2026-08-19T05:04:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/6f/f8/1ef6fc7070bed8753315f2e4ea66bc0d37620e1444d014db7f0267b8faaf/lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9", size = 4744614, upload-time = "2026-08-19T05:04:12.717Z" },
{ url = "https://files.pythonhosted.org/packages/87/f6/3a4824cd1c1b81d996d2d75bbd176ba13fbe9b5d89489290d93ff9558486/lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00", size = 5685792, upload-time = "2026-08-19T05:04:15.367Z" },
{ url = "https://files.pythonhosted.org/packages/64/9a/f133bf16a67149e00ca5d8a8f1ae662c30a86c303aa242693b67f8e19856/lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7", size = 5248972, upload-time = "2026-08-19T05:04:18.491Z" },
{ url = "https://files.pythonhosted.org/packages/50/63/273e7e8a73a5d183d8552dfdaa131dfda0292ddab7bcddc5a66a0ae525d8/lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060", size = 5271809, upload-time = "2026-08-19T05:04:21.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/eb/614117c36a28909e79ff7cdec87008f0bd996478f35cf72309189cf398b1/lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f", size = 3662854, upload-time = "2026-08-19T05:05:07.141Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/06aee6107cf8e7b870f10f82539f366cba10dc6053144cca80e838caf8c8/lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b", size = 4074590, upload-time = "2026-08-19T05:05:09.697Z" },
{ url = "https://files.pythonhosted.org/packages/84/bf/dad9b6baf9b26d79584834e15cef2a5dd0a13c7b1df08831e8f18244b494/lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa", size = 3749336, upload-time = "2026-08-19T05:05:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9d/cd0c43d45e2eb52df7735c6558f24054ca633499191899b0cb9040fbbc3c/lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86", size = 8857688, upload-time = "2026-08-19T05:04:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/0b/26/27093dc1a9edbdd8a54652f237a387f7e63ec0192efe708bc2576d8a1383/lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111", size = 4754422, upload-time = "2026-08-19T05:04:27.645Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ee/502f7c93507f57eb496744a64da8f4ca86855cf88e48d14584342f1bfd92/lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41", size = 5033396, upload-time = "2026-08-19T05:04:30.054Z" },
{ url = "https://files.pythonhosted.org/packages/bf/72/c4cbbe72f951650f2afe43a70e51687e111d82b9bec46e3310ea76419d46/lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715", size = 5615298, upload-time = "2026-08-19T05:04:32.752Z" },
{ url = "https://files.pythonhosted.org/packages/14/83/a3df966d6d7b6513e9dfb6fbfb041c0619642170359c1b36ab20a83e59eb/lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818", size = 5236282, upload-time = "2026-08-19T05:04:35.762Z" },
{ url = "https://files.pythonhosted.org/packages/4f/85/8692ec8173c9f8d295735b9bf410d202317e7b3ed11141e80a30f421f409/lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5", size = 4650647, upload-time = "2026-08-19T05:04:38.396Z" },
{ url = "https://files.pythonhosted.org/packages/11/e7/dbe3cece28a5bf82997a091d9dbb0fc49e725a5fa86550897ee2cf6412e6/lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb", size = 5243387, upload-time = "2026-08-19T05:04:41.247Z" },
{ url = "https://files.pythonhosted.org/packages/99/a9/81a2d27640db0d27200b2f32339a54e74c36d58feb5ad528b87d52a59ecc/lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9", size = 5092624, upload-time = "2026-08-19T05:04:43.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f4/0b0304c70c087f618d95b0306738b070bd556afd09c2c92589b78dbe5eb0/lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1", size = 4758742, upload-time = "2026-08-19T05:04:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ec/f9fc45f1d01b632b673e11880e75292dff9953db9f426d1a38201b8eb5f5/lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e", size = 5649540, upload-time = "2026-08-19T05:04:49.777Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0b/d65e0458c2bcce0df68d5cc29ad0006e76446f02d9e50caf188fd1fb8bae/lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c", size = 5234869, upload-time = "2026-08-19T05:04:52.972Z" },
{ url = "https://files.pythonhosted.org/packages/ee/62/1fee828238badd3bfe9544f5cc9ce6ded421ef38e9634030445dedd78b36/lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088", size = 5259992, upload-time = "2026-08-19T05:04:56.028Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/35fb14dd6baccbffa6daeb2369802f04a94e3f73db3c7bb405dbab009729/lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6", size = 3901151, upload-time = "2026-08-19T05:04:58.671Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/07530896ca062bc3d2f09d5cb8a48e799c05b12c496205db03159ba13b6c/lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0", size = 4395355, upload-time = "2026-08-19T05:05:01.651Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/237d8de1d77085cfd41d0c6049a044d8d01886f3afb7f1eda2f43d900a96/lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b", size = 3822823, upload-time = "2026-08-19T05:05:04.63Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c9/11bfea1b3afc7a27ce74222b2e12b97005f3b81aa0011313769a14afd60a/lxml-6.1.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4622c5616683faf63791b349e6c8dad7717412dc5f29f4febe7575f110609a86", size = 3942892, upload-time = "2026-08-19T05:03:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/c4/98/9885a4505758885c113af2bc2335a9fced99cb01e07e42895a62f1eb97fb/lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:733dfb492ec3dfef8350a5cc896e90d202c5171e791e1609e77563751d69a15d", size = 4213061, upload-time = "2026-08-19T05:03:24.259Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/e80d9e7d6e54b0693df60c7eeeed4aa19e2e3936dadf0676e6a3e8ac1ee1/lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4618b20f43dc98b49569b1dc822176140ea0f2598d672a6989187ba49bcbfec1", size = 4322013, upload-time = "2026-08-19T05:03:26.764Z" },
{ url = "https://files.pythonhosted.org/packages/52/22/2e896cfba4e86b805eb8a3259cbdc1601971dc8fda5b1db2044ec2a3e6f0/lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc5e25992f5545709000d840c6cafdbd022781a7a0ed79d58a5633733a4e8", size = 4257333, upload-time = "2026-08-19T05:03:29.355Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1d/9dbdbfa284ea96aee7c368e0ac73994f7e1375281070c355bcd85d4f7a77/lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:662432a6103e671d971e06e75ed146d9ff67f39d2c98c2f26613b6057f54eafc", size = 4410828, upload-time = "2026-08-19T05:03:31.948Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f2/fea24b044219458c252e0a0a08074a27dc9e28edb85f83533e36e3ddb57d/lxml-6.1.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ba0dfead73be5be9ad0b7fbf9f31ff29c1b1eae858816dfc8d85099d6e4af0d6", size = 3511278, upload-time = "2026-08-19T05:03:34.597Z" },
]
[[package]]
@@ -2066,11 +2096,11 @@ wheels = [
[[package]]
name = "narwhals"
version = "2.24.0"
version = "2.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" },
]
[[package]]
@@ -2670,11 +2700,11 @@ wheels = [
[[package]]
name = "pygments"
version = "2.20.0"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
@@ -2777,11 +2807,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.2"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]