python-entsoe 0.6.1__tar.gz → 0.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_entsoe-0.6.2/.github/workflows/publish.yml +26 -0
- python_entsoe-0.6.2/.gitlab-ci.yml +22 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/CHANGELOG.md +10 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/CLAUDE.md +8 -4
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/PKG-INFO +11 -8
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/README.md +5 -2
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/docs/data-availability.md +24 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/pyproject.toml +5 -5
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/_xml.py +56 -7
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cache.py +41 -10
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/prices.py +2 -1
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/_base.py +26 -14
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/prices.py +20 -1
- python_entsoe-0.6.2/tests/test_cache_layout.py +23 -0
- python_entsoe-0.6.2/tests/test_xml_day_ahead.py +99 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/uv.lock +2 -2
- python_entsoe-0.6.1/.github/workflows/publish.yml +0 -42
- python_entsoe-0.6.1/.release-please-manifest.json +0 -3
- python_entsoe-0.6.1/release-please-config.json +0 -19
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/.gitignore +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/.python-version +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/balancing.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/generation.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/generation_per_plant.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/load.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/prices.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/examples/transmission.ipynb +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/scripts/generate_notebooks.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/.agents/skills/entsoe/SKILL.md +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/__init__.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/_http.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/_mappings.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/catalog.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/__init__.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/_output.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/_resolve.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/app.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/balancing.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/cache_cmd.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/catalog_cmd.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/config.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/config_cmd.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/exec_cmd.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/generation.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/load.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/cli/transmission.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/client.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/exceptions.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/__init__.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/balancing.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/generation.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/load.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/namespaces/transmission.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/src/entsoe/py.typed +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/__init__.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/conftest.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_balancing.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_generation.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_load.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_multi.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_prices.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_transmission.py +0 -0
- {python_entsoe-0.6.1 → python_entsoe-0.6.2}/tests/test_validation.py +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
|
|
19
|
+
- name: Install build tools
|
|
20
|
+
run: pip install build
|
|
21
|
+
|
|
22
|
+
- name: Build package
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- name: Publish to PyPI
|
|
26
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
stages:
|
|
2
|
+
- build
|
|
3
|
+
- publish
|
|
4
|
+
|
|
5
|
+
default:
|
|
6
|
+
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
|
7
|
+
|
|
8
|
+
build:
|
|
9
|
+
stage: build
|
|
10
|
+
script:
|
|
11
|
+
- uv build --no-sources
|
|
12
|
+
artifacts:
|
|
13
|
+
paths:
|
|
14
|
+
- dist/
|
|
15
|
+
|
|
16
|
+
publish:
|
|
17
|
+
stage: publish
|
|
18
|
+
rules:
|
|
19
|
+
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+/'
|
|
20
|
+
script:
|
|
21
|
+
- uv build --no-sources
|
|
22
|
+
- uv publish --publish-url "$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi" --check-url "$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi/simple" --username gitlab-ci-token --password "$CI_JOB_TOKEN"
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.2](https://gitlab.com/datons.com/libraries/python-entsoe/-/tags/v0.6.2) (2026-10-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **prices:** `day_ahead` mixed the SDAC and EXAA auctions for DE_LU and AT, and the intraday auctions for ES, returning several prices per timestamp. It now returns SDAC; `sequence=2` returns EXAA (CLI: `--sequence 2`).
|
|
9
|
+
* **parser:** points omitted from A03 (variable-sized block) curves are filled with the previous value, so no interval is lost. Affects every endpoint.
|
|
10
|
+
* **parser:** TimeSeries that the API repeats verbatim no longer produce duplicate rows.
|
|
11
|
+
* **cache:** the cache moves to a versioned layout (`~/.cache/entsoe/v2/`). Data cached by earlier versions, which may hold the errors above, is deleted on first use and downloaded again.
|
|
12
|
+
|
|
3
13
|
## [0.6.1](https://github.com/datons/python-entsoe/compare/python-entsoe-v0.6.0...python-entsoe-v0.6.1) (2026-05-16)
|
|
4
14
|
|
|
5
15
|
|
|
@@ -9,19 +9,23 @@ Python client for the ENTSO-E Transparency Platform API.
|
|
|
9
9
|
- Run scripts: `uv run python <script>`
|
|
10
10
|
- Run tests: `uv run pytest`
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Publishing
|
|
13
13
|
|
|
14
|
-
Publishing is **tag-based only
|
|
14
|
+
The repo lives in GitLab (`datons.com/libraries/python-entsoe`, private). Publishing is **tag-based only**: pushing to `main` does NOT release. The CI publishes to the `datons.com/libraries` package registry, which `esios` and the other services consume through the `datons-libraries` index.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
# 1. Bump version in pyproject.toml
|
|
18
18
|
# 2. Commit the version bump
|
|
19
19
|
# 3. Tag and push
|
|
20
|
-
git tag v0.
|
|
20
|
+
git tag v0.6.2
|
|
21
21
|
git push origin main --tags
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Public users install from PyPI, so a fix that matters to them is also released there: push `main` and the same tag to the `github` remote. `.github/workflows/publish.yml` builds and publishes on `v*` tags through PyPI trusted publishing (no token). GitLab stays the source of truth.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git push github main --tags
|
|
28
|
+
```
|
|
25
29
|
|
|
26
30
|
## Example Notebooks
|
|
27
31
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-entsoe
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Python client for the ENTSO-E Transparency Platform API
|
|
5
|
-
Project-URL: Homepage, https://
|
|
6
|
-
Project-URL: Repository, https://
|
|
7
|
-
Project-URL: Issues, https://
|
|
8
|
-
Project-URL: Changelog, https://
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/datons.com/libraries/python-entsoe
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/datons.com/libraries/python-entsoe
|
|
7
|
+
Project-URL: Issues, https://gitlab.com/datons.com/libraries/python-entsoe/-/issues
|
|
8
|
+
Project-URL: Changelog, https://gitlab.com/datons.com/libraries/python-entsoe/-/releases
|
|
9
9
|
Author-email: Jesús López <jesus.lopez@datons.com>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
Keywords: api,electricity,energy,entsoe,transparency
|
|
@@ -93,11 +93,14 @@ df = client.load.forecast(start, end, country="FR")
|
|
|
93
93
|
|
|
94
94
|
| Method | Description | Parameters |
|
|
95
95
|
|--------|-------------|------------|
|
|
96
|
-
| `day_ahead(start, end, country)` | Day-ahead market prices (EUR/MWh) | `country`: ISO code |
|
|
96
|
+
| `day_ahead(start, end, country, sequence=1)` | Day-ahead market prices (EUR/MWh) | `country`: ISO code; `sequence`: `1` SDAC, `2` EXAA |
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
99
|
df = client.prices.day_ahead(start, end, country="FR")
|
|
100
100
|
# Returns: timestamp, value, currency, price_unit
|
|
101
|
+
|
|
102
|
+
# DE_LU and AT also publish the separate EXAA auction (10:15 CET)
|
|
103
|
+
exaa = client.prices.day_ahead(start, end, country="DE_LU", sequence=2)
|
|
101
104
|
```
|
|
102
105
|
|
|
103
106
|
### `client.generation`
|
|
@@ -251,7 +254,7 @@ See the [`examples/`](examples/) directory for Jupyter notebooks with plotly vis
|
|
|
251
254
|
## Development
|
|
252
255
|
|
|
253
256
|
```bash
|
|
254
|
-
git clone
|
|
257
|
+
git clone git@gitlab.com:datons.com/libraries/python-entsoe.git
|
|
255
258
|
cd python-entsoe
|
|
256
259
|
uv sync
|
|
257
260
|
|
|
@@ -65,11 +65,14 @@ df = client.load.forecast(start, end, country="FR")
|
|
|
65
65
|
|
|
66
66
|
| Method | Description | Parameters |
|
|
67
67
|
|--------|-------------|------------|
|
|
68
|
-
| `day_ahead(start, end, country)` | Day-ahead market prices (EUR/MWh) | `country`: ISO code |
|
|
68
|
+
| `day_ahead(start, end, country, sequence=1)` | Day-ahead market prices (EUR/MWh) | `country`: ISO code; `sequence`: `1` SDAC, `2` EXAA |
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
71
|
df = client.prices.day_ahead(start, end, country="FR")
|
|
72
72
|
# Returns: timestamp, value, currency, price_unit
|
|
73
|
+
|
|
74
|
+
# DE_LU and AT also publish the separate EXAA auction (10:15 CET)
|
|
75
|
+
exaa = client.prices.day_ahead(start, end, country="DE_LU", sequence=2)
|
|
73
76
|
```
|
|
74
77
|
|
|
75
78
|
### `client.generation`
|
|
@@ -223,7 +226,7 @@ See the [`examples/`](examples/) directory for Jupyter notebooks with plotly vis
|
|
|
223
226
|
## Development
|
|
224
227
|
|
|
225
228
|
```bash
|
|
226
|
-
git clone
|
|
229
|
+
git clone git@gitlab.com:datons.com/libraries/python-entsoe.git
|
|
227
230
|
cd python-entsoe
|
|
228
231
|
uv sync
|
|
229
232
|
|
|
@@ -92,6 +92,28 @@ environment variable.
|
|
|
92
92
|
export $(cat .env | xargs) && uv run jupyter nbconvert --execute ...
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## 7. Day-ahead documents (A44) mix several auctions
|
|
96
|
+
|
|
97
|
+
**Affected endpoints:** `prices.day_ahead`
|
|
98
|
+
|
|
99
|
+
**Problem:** One A44 response can hold several price series for the same hours:
|
|
100
|
+
|
|
101
|
+
- DE_LU and AT publish the SDAC auction (gate closure 12:00 CET) as `classificationSequence_AttributeInstanceComponent.position` 1 and the EXAA auction (10:15 CET) as position 2.
|
|
102
|
+
- ES also returns the intraday auctions (IDA1–3) under `contract_MarketAgreement.type` A07, numbered by the same sequence element.
|
|
103
|
+
- Some zones (FR, AT) repeat an identical TimeSeries.
|
|
104
|
+
|
|
105
|
+
Parsing every series gives two or more prices per timestamp.
|
|
106
|
+
|
|
107
|
+
**Solution:** Keep only daily contracts (`A01`, or no contract type) whose sequence matches the requested one, treating a missing sequence as 1. `day_ahead(..., sequence=2)` returns EXAA. Identical rows are dropped.
|
|
108
|
+
|
|
109
|
+
## 8. Curve type A03 omits repeated points
|
|
110
|
+
|
|
111
|
+
**Affected endpoints:** all (prices, load, generation, transmission)
|
|
112
|
+
|
|
113
|
+
**Problem:** With `<curveType>A03</curveType>` (variable-sized blocks), a Point applies until the next Point, and positions whose value equals the previous one are left out. Reading only the Points present loses those intervals: a day of quarter-hourly prices can come back with 95 rows, and a flat generation series with a single row.
|
|
114
|
+
|
|
115
|
+
**Solution:** Fill every position up to the Period's `timeInterval/end` with the last value seen. The Period end tracks published data, so recent partial days are not extended.
|
|
116
|
+
|
|
95
117
|
## Summary table
|
|
96
118
|
|
|
97
119
|
| Issue | Endpoint | Symptom | Fix |
|
|
@@ -102,3 +124,5 @@ export $(cat .env | xargs) && uv run jupyter nbconvert --execute ...
|
|
|
102
124
|
| Route availability | net_transfer_capacity | `NoDataError` for FR→DE_LU | Use FR→ES, FR→GB, FR→IT_NORTH |
|
|
103
125
|
| Value tag name | imbalance_prices | All values are `None` | Parse `imbalance_Price.amount` tag |
|
|
104
126
|
| Env vars in notebooks | all (via nbconvert) | `ValueError: API key required` | Export env vars before execution |
|
|
127
|
+
| Mixed auctions | prices.day_ahead | Duplicate timestamps (DE_LU, AT, ES) | Filter by contract type and sequence |
|
|
128
|
+
| A03 omitted points | all | Missing intervals | Forward-fill to Period end |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-entsoe"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.2"
|
|
4
4
|
description = "Python client for the ENTSO-E Transparency Platform API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,10 +32,10 @@ dependencies = [
|
|
|
32
32
|
entsoe = "entsoe.cli:app"
|
|
33
33
|
|
|
34
34
|
[project.urls]
|
|
35
|
-
Homepage = "https://
|
|
36
|
-
Repository = "https://
|
|
37
|
-
Issues = "https://
|
|
38
|
-
Changelog = "https://
|
|
35
|
+
Homepage = "https://gitlab.com/datons.com/libraries/python-entsoe"
|
|
36
|
+
Repository = "https://gitlab.com/datons.com/libraries/python-entsoe"
|
|
37
|
+
Issues = "https://gitlab.com/datons.com/libraries/python-entsoe/-/issues"
|
|
38
|
+
Changelog = "https://gitlab.com/datons.com/libraries/python-entsoe/-/releases"
|
|
39
39
|
|
|
40
40
|
[build-system]
|
|
41
41
|
requires = ["hatchling"]
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
import re
|
|
16
16
|
import xml.etree.ElementTree as ET
|
|
17
17
|
from datetime import timedelta
|
|
18
|
+
from typing import Callable
|
|
18
19
|
|
|
19
20
|
import pandas as pd
|
|
20
21
|
|
|
@@ -79,12 +80,42 @@ def _extract_point_value(point: ET.Element) -> float | None:
|
|
|
79
80
|
return None
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
#: Element holding the sequence of a TimeSeries within a document, e.g. the
|
|
84
|
+
#: SDAC (1) and EXAA (2) day-ahead auctions both published for DE_LU and AT.
|
|
85
|
+
SEQUENCE_TAG = "classificationSequence_AttributeInstanceComponent.position"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def timeseries_sequence(ts: ET.Element) -> int | None:
|
|
89
|
+
"""Return the classification sequence of a TimeSeries, or None if absent."""
|
|
90
|
+
for el in ts.iter():
|
|
91
|
+
if _strip_ns(el.tag) == SEQUENCE_TAG and el.text:
|
|
92
|
+
return int(el.text)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def timeseries_contract_type(ts: ET.Element) -> str | None:
|
|
97
|
+
"""Return the contract_MarketAgreement.type of a TimeSeries (A01 daily, A07 intraday)."""
|
|
98
|
+
return _find_text(ts, "contract_MarketAgreement.type")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_timeseries(
|
|
102
|
+
xml_text: str,
|
|
103
|
+
ts_filter: Callable[[ET.Element], bool] | None = None,
|
|
104
|
+
) -> pd.DataFrame:
|
|
83
105
|
"""Parse an ENTSO-E XML response into a DataFrame.
|
|
84
106
|
|
|
85
107
|
Handles multiple TimeSeries (e.g., generation per type) and multiple
|
|
86
108
|
Periods within each TimeSeries (e.g., multi-day responses).
|
|
87
109
|
|
|
110
|
+
Points omitted from a curveType A03 (variable-sized block) Period carry
|
|
111
|
+
the previous point's value, so they are filled in. TimeSeries the API
|
|
112
|
+
repeats verbatim produce duplicate rows, which are dropped.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
xml_text: Raw XML response.
|
|
116
|
+
ts_filter: Optional predicate on each TimeSeries element; series for
|
|
117
|
+
which it returns False are skipped.
|
|
118
|
+
|
|
88
119
|
Returns a DataFrame with columns:
|
|
89
120
|
- timestamp: tz-aware UTC datetime
|
|
90
121
|
- value: numeric value (MW or EUR/MWh depending on query)
|
|
@@ -107,6 +138,9 @@ def parse_timeseries(xml_text: str) -> pd.DataFrame:
|
|
|
107
138
|
rows: list[dict] = []
|
|
108
139
|
|
|
109
140
|
for ts in timeseries_list:
|
|
141
|
+
if ts_filter is not None and not ts_filter(ts):
|
|
142
|
+
continue
|
|
143
|
+
|
|
110
144
|
# Extract TimeSeries metadata
|
|
111
145
|
ts_meta: dict = {}
|
|
112
146
|
|
|
@@ -163,23 +197,38 @@ def parse_timeseries(xml_text: str) -> pd.DataFrame:
|
|
|
163
197
|
period_start = pd.Timestamp(start_text)
|
|
164
198
|
resolution = _parse_resolution(resolution_text)
|
|
165
199
|
|
|
200
|
+
values: dict[int, float | None] = {}
|
|
166
201
|
for point in _findall(period, "Point"):
|
|
167
202
|
position_text = _find_text(point, "position")
|
|
168
203
|
if position_text is None:
|
|
169
204
|
continue
|
|
205
|
+
values[int(position_text)] = _extract_point_value(point)
|
|
170
206
|
|
|
171
|
-
|
|
172
|
-
|
|
207
|
+
if not values:
|
|
208
|
+
continue
|
|
173
209
|
|
|
210
|
+
positions = sorted(values)
|
|
211
|
+
if _find_text(ts, "curveType") == "A03":
|
|
212
|
+
end_text = _find_text(time_interval, "end")
|
|
213
|
+
if end_text is not None:
|
|
214
|
+
n_slots = int((pd.Timestamp(end_text) - period_start) / resolution)
|
|
215
|
+
else:
|
|
216
|
+
n_slots = positions[-1]
|
|
217
|
+
filled: dict[int, float | None] = {}
|
|
218
|
+
last = None
|
|
219
|
+
for pos in range(positions[0], max(n_slots, positions[-1]) + 1):
|
|
220
|
+
last = values.get(pos, last)
|
|
221
|
+
filled[pos] = last
|
|
222
|
+
values, positions = filled, list(filled)
|
|
223
|
+
|
|
224
|
+
for position in positions:
|
|
174
225
|
timestamp = period_start + resolution * (position - 1)
|
|
175
|
-
|
|
176
|
-
row = {"timestamp": timestamp, "value": value, **ts_meta}
|
|
177
|
-
rows.append(row)
|
|
226
|
+
rows.append({"timestamp": timestamp, "value": values[position], **ts_meta})
|
|
178
227
|
|
|
179
228
|
if not rows:
|
|
180
229
|
raise NoDataError()
|
|
181
230
|
|
|
182
|
-
df = pd.DataFrame(rows)
|
|
231
|
+
df = pd.DataFrame(rows).drop_duplicates()
|
|
183
232
|
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
|
|
184
233
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
|
185
234
|
return df
|
|
@@ -6,7 +6,7 @@ published (~48h), so caching is safe and enabled by default.
|
|
|
6
6
|
|
|
7
7
|
Storage layout::
|
|
8
8
|
|
|
9
|
-
{cache_dir}/
|
|
9
|
+
{cache_dir}/v{CACHE_LAYOUT_VERSION}/
|
|
10
10
|
├── prices/day_ahead/{ES,FR,...}/data.parquet
|
|
11
11
|
├── load/{actual,forecast}/{ES,FR,...}/data.parquet
|
|
12
12
|
├── generation/{actual,forecast,capacity}/{ES,...}/data.parquet
|
|
@@ -18,6 +18,9 @@ Each partition stores a single wide-format parquet file: the DatetimeIndex
|
|
|
18
18
|
holds timestamps, and columns are values (e.g. "value", or per-type
|
|
19
19
|
columns like "solar", "wind_onshore").
|
|
20
20
|
|
|
21
|
+
The version directory changes whenever cached values from earlier releases
|
|
22
|
+
are known to be wrong; data from older layouts is removed on first use.
|
|
23
|
+
|
|
21
24
|
Gap detection works per-column: requesting specific columns checks whether
|
|
22
25
|
those columns have data for the requested range, not whether any column does.
|
|
23
26
|
"""
|
|
@@ -42,6 +45,13 @@ _DEFAULT_CACHE_DIR = Path(
|
|
|
42
45
|
os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
|
|
43
46
|
) / "entsoe"
|
|
44
47
|
|
|
48
|
+
# Bumped when previously cached values are known to be wrong.
|
|
49
|
+
# v2 (0.6.2): A03 curves lost their omitted points; day-ahead mixed auctions.
|
|
50
|
+
CACHE_LAYOUT_VERSION = 2
|
|
51
|
+
|
|
52
|
+
# Domain directories written by the unversioned (<= 0.6.1) layout.
|
|
53
|
+
_LEGACY_DOMAINS = ("prices", "load", "generation", "transmission", "balancing")
|
|
54
|
+
|
|
45
55
|
# Data older than this (hours) is considered final and won't be re-fetched
|
|
46
56
|
_DEFAULT_RECENT_TTL_HOURS = 48
|
|
47
57
|
|
|
@@ -71,16 +81,37 @@ class CacheStore:
|
|
|
71
81
|
|
|
72
82
|
def __init__(self, config: CacheConfig):
|
|
73
83
|
self.config = config
|
|
84
|
+
self._purge_stale_layouts()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def root(self) -> Path:
|
|
88
|
+
"""Directory holding the current cache layout."""
|
|
89
|
+
return self.config.cache_dir / f"v{CACHE_LAYOUT_VERSION}"
|
|
90
|
+
|
|
91
|
+
def _purge_stale_layouts(self) -> None:
|
|
92
|
+
"""Delete data cached by older layouts, which may hold wrong values."""
|
|
93
|
+
base = self.config.cache_dir
|
|
94
|
+
if not base.is_dir():
|
|
95
|
+
return
|
|
96
|
+
stale = [base / d for d in _LEGACY_DOMAINS]
|
|
97
|
+
stale += [
|
|
98
|
+
p for p in base.glob("v*")
|
|
99
|
+
if p.name[1:].isdigit() and int(p.name[1:]) < CACHE_LAYOUT_VERSION
|
|
100
|
+
]
|
|
101
|
+
for path in stale:
|
|
102
|
+
if path.is_dir():
|
|
103
|
+
logger.info("Removing outdated ENTSO-E cache: %s", path)
|
|
104
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
74
105
|
|
|
75
106
|
# -- Path resolution -------------------------------------------------------
|
|
76
107
|
|
|
77
108
|
def _parquet_path(self, domain: str, topic: str, partition: str) -> Path:
|
|
78
|
-
"""Data file: {
|
|
79
|
-
return self.
|
|
109
|
+
"""Data file: {root}/{domain}/{topic}/{partition}/data.parquet"""
|
|
110
|
+
return self.root / domain / topic / partition / "data.parquet"
|
|
80
111
|
|
|
81
112
|
def _meta_path(self, domain: str, topic: str, partition: str) -> Path:
|
|
82
|
-
"""Metadata file: {
|
|
83
|
-
return self.
|
|
113
|
+
"""Metadata file: {root}/{domain}/{topic}/{partition}/meta.json"""
|
|
114
|
+
return self.root / domain / topic / partition / "meta.json"
|
|
84
115
|
|
|
85
116
|
# -- Data Read / Write -----------------------------------------------------
|
|
86
117
|
|
|
@@ -291,13 +322,13 @@ class CacheStore:
|
|
|
291
322
|
count = 0
|
|
292
323
|
|
|
293
324
|
if domain and topic and partition:
|
|
294
|
-
target = self.
|
|
325
|
+
target = self.root / domain / topic / partition
|
|
295
326
|
elif domain and topic:
|
|
296
|
-
target = self.
|
|
327
|
+
target = self.root / domain / topic
|
|
297
328
|
elif domain:
|
|
298
|
-
target = self.
|
|
329
|
+
target = self.root / domain
|
|
299
330
|
else:
|
|
300
|
-
target = self.
|
|
331
|
+
target = self.root
|
|
301
332
|
|
|
302
333
|
if target.exists():
|
|
303
334
|
count = sum(1 for f in target.rglob("*") if f.is_file())
|
|
@@ -307,7 +338,7 @@ class CacheStore:
|
|
|
307
338
|
|
|
308
339
|
def status(self) -> dict:
|
|
309
340
|
"""Return cache statistics."""
|
|
310
|
-
cache_dir = self.
|
|
341
|
+
cache_dir = self.root
|
|
311
342
|
if not cache_dir.exists():
|
|
312
343
|
return {"path": str(cache_dir), "files": 0, "size_mb": 0.0, "domains": {}}
|
|
313
344
|
|
|
@@ -18,6 +18,7 @@ def day_ahead(
|
|
|
18
18
|
end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (YYYY-MM-DD)"),
|
|
19
19
|
period: Optional[str] = typer.Option(None, "--period", "-P", help="Period shorthand: today, yesterday, week, month, ytd, <N>d"),
|
|
20
20
|
country: list[str] = typer.Option(..., "--country", "-c", help="Country code(s), comma-separated or repeated (e.g. ES,FR,DE_LU)"),
|
|
21
|
+
sequence: int = typer.Option(1, "--sequence", help="Auction: 1 = SDAC (default), 2 = EXAA (DE_LU, AT)"),
|
|
21
22
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
|
|
22
23
|
output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
23
24
|
api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
|
|
@@ -28,7 +29,7 @@ def day_ahead(
|
|
|
28
29
|
s, e = resolve_dates(start, end, period)
|
|
29
30
|
client = get_client(api_key)
|
|
30
31
|
countries = resolve_many_required(country)
|
|
31
|
-
df = client.prices.day_ahead(s, e, country=countries)
|
|
32
|
+
df = client.prices.day_ahead(s, e, country=countries, sequence=sequence)
|
|
32
33
|
output(df, format, title="Day-Ahead Prices", output_path=output_path)
|
|
33
34
|
|
|
34
35
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from typing import TYPE_CHECKING, Callable, Union
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, Optional, Union
|
|
7
7
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
|
|
@@ -11,6 +11,8 @@ from ..exceptions import InvalidParameterError, NoDataError
|
|
|
11
11
|
from .._mappings import country_name, lookup_area
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
+
import xml.etree.ElementTree as ET
|
|
15
|
+
|
|
14
16
|
from .._http import HttpClient
|
|
15
17
|
from ..cache import CacheStore
|
|
16
18
|
|
|
@@ -22,6 +24,9 @@ Timestamp = Union[str, pd.Timestamp]
|
|
|
22
24
|
#: Parameters that accept a single value or a list.
|
|
23
25
|
OneOrMany = Union[str, list[str]]
|
|
24
26
|
|
|
27
|
+
#: Predicate deciding which TimeSeries of a response to keep.
|
|
28
|
+
TimeSeriesFilter = Optional[Callable[["ET.Element"], bool]]
|
|
29
|
+
|
|
25
30
|
|
|
26
31
|
class BaseNamespace:
|
|
27
32
|
"""Base class for domain namespaces.
|
|
@@ -61,6 +66,8 @@ class BaseNamespace:
|
|
|
61
66
|
params: dict,
|
|
62
67
|
start: Timestamp,
|
|
63
68
|
end: Timestamp,
|
|
69
|
+
*,
|
|
70
|
+
ts_filter: TimeSeriesFilter = None,
|
|
64
71
|
) -> pd.DataFrame:
|
|
65
72
|
"""Execute a query and parse the XML response into a DataFrame."""
|
|
66
73
|
from .._xml import parse_timeseries
|
|
@@ -71,10 +78,10 @@ class BaseNamespace:
|
|
|
71
78
|
|
|
72
79
|
# Handle multi-chunk responses (year-splitting)
|
|
73
80
|
if isinstance(result, list):
|
|
74
|
-
dfs = [parse_timeseries(xml) for xml in result]
|
|
81
|
+
dfs = [parse_timeseries(xml, ts_filter) for xml in result]
|
|
75
82
|
return pd.concat(dfs, ignore_index=True).sort_values("timestamp").reset_index(drop=True)
|
|
76
83
|
|
|
77
|
-
return parse_timeseries(result)
|
|
84
|
+
return parse_timeseries(result, ts_filter)
|
|
78
85
|
|
|
79
86
|
def _query_multi(
|
|
80
87
|
self,
|
|
@@ -85,6 +92,7 @@ class BaseNamespace:
|
|
|
85
92
|
*,
|
|
86
93
|
label_fn: Callable[[str], str] = country_name,
|
|
87
94
|
label_col: str = "country",
|
|
95
|
+
ts_filter: TimeSeriesFilter = None,
|
|
88
96
|
) -> pd.DataFrame:
|
|
89
97
|
"""Execute a query for one or many values, concatenating results.
|
|
90
98
|
|
|
@@ -110,11 +118,11 @@ class BaseNamespace:
|
|
|
110
118
|
queries with an additional label column.
|
|
111
119
|
"""
|
|
112
120
|
if isinstance(values, str):
|
|
113
|
-
return self._query(build_params(values), start, end)
|
|
121
|
+
return self._query(build_params(values), start, end, ts_filter=ts_filter)
|
|
114
122
|
|
|
115
123
|
frames = []
|
|
116
124
|
for val in values:
|
|
117
|
-
df = self._query(build_params(val), start, end)
|
|
125
|
+
df = self._query(build_params(val), start, end, ts_filter=ts_filter)
|
|
118
126
|
df[label_col] = label_fn(val)
|
|
119
127
|
frames.append(df)
|
|
120
128
|
return pd.concat(frames, ignore_index=True)
|
|
@@ -134,6 +142,7 @@ class BaseNamespace:
|
|
|
134
142
|
*,
|
|
135
143
|
pivot_col: str | None = None,
|
|
136
144
|
value_col: str = "value",
|
|
145
|
+
ts_filter: TimeSeriesFilter = None,
|
|
137
146
|
) -> pd.DataFrame:
|
|
138
147
|
"""Execute a query with parquet cache support.
|
|
139
148
|
|
|
@@ -174,7 +183,7 @@ class BaseNamespace:
|
|
|
174
183
|
raise InvalidParameterError("start must be before end.")
|
|
175
184
|
|
|
176
185
|
if self._cache is None:
|
|
177
|
-
return self._query(params, start_ts, end_ts)
|
|
186
|
+
return self._query(params, start_ts, end_ts, ts_filter=ts_filter)
|
|
178
187
|
|
|
179
188
|
# 1. Read cached wide data
|
|
180
189
|
cached_wide = self._cache.read(domain, topic, partition, start_ts, end_ts)
|
|
@@ -225,7 +234,7 @@ class BaseNamespace:
|
|
|
225
234
|
gap_meta: dict = {}
|
|
226
235
|
for gap in valid_gaps:
|
|
227
236
|
try:
|
|
228
|
-
raw_df = self._query(params, gap.start, gap.end)
|
|
237
|
+
raw_df = self._query(params, gap.start, gap.end, ts_filter=ts_filter)
|
|
229
238
|
wide, meta = self._to_wide(raw_df, pivot_col, value_col)
|
|
230
239
|
new_wide_frames.append(wide)
|
|
231
240
|
if meta:
|
|
@@ -279,27 +288,30 @@ class BaseNamespace:
|
|
|
279
288
|
value_col: str = "value",
|
|
280
289
|
label_fn: Callable[[str], str] = country_name,
|
|
281
290
|
label_col: str = "country",
|
|
291
|
+
ts_filter: TimeSeriesFilter = None,
|
|
292
|
+
partition_fn: Callable[[str], str] = str,
|
|
282
293
|
) -> pd.DataFrame:
|
|
283
294
|
"""Execute a cached query for one or many values.
|
|
284
295
|
|
|
285
|
-
Each value is its own cache partition
|
|
286
|
-
a label column, same as :meth:`_query_multi`.
|
|
296
|
+
Each value is its own cache partition, named by *partition_fn(value)*.
|
|
297
|
+
Multi-value queries add a label column, same as :meth:`_query_multi`.
|
|
287
298
|
"""
|
|
288
299
|
if self._cache is None:
|
|
289
300
|
return self._query_multi(build_params, values, start, end,
|
|
290
|
-
label_fn=label_fn, label_col=label_col
|
|
301
|
+
label_fn=label_fn, label_col=label_col,
|
|
302
|
+
ts_filter=ts_filter)
|
|
291
303
|
|
|
292
304
|
if isinstance(values, str):
|
|
293
305
|
return self._query_cached(
|
|
294
|
-
domain, topic, values, build_params(values), start, end,
|
|
295
|
-
pivot_col=pivot_col, value_col=value_col,
|
|
306
|
+
domain, topic, partition_fn(values), build_params(values), start, end,
|
|
307
|
+
pivot_col=pivot_col, value_col=value_col, ts_filter=ts_filter,
|
|
296
308
|
)
|
|
297
309
|
|
|
298
310
|
frames = []
|
|
299
311
|
for val in values:
|
|
300
312
|
df = self._query_cached(
|
|
301
|
-
domain, topic, val, build_params(val), start, end,
|
|
302
|
-
pivot_col=pivot_col, value_col=value_col,
|
|
313
|
+
domain, topic, partition_fn(val), build_params(val), start, end,
|
|
314
|
+
pivot_col=pivot_col, value_col=value_col, ts_filter=ts_filter,
|
|
303
315
|
)
|
|
304
316
|
df[label_col] = label_fn(val)
|
|
305
317
|
frames.append(df)
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
5
7
|
import pandas as pd
|
|
6
8
|
|
|
9
|
+
from .._xml import timeseries_contract_type, timeseries_sequence
|
|
7
10
|
from ._base import BaseNamespace, OneOrMany, Timestamp
|
|
8
11
|
|
|
9
|
-
|
|
10
12
|
class PricesNamespace(BaseNamespace):
|
|
11
13
|
"""Access electricity price data.
|
|
12
14
|
|
|
@@ -19,14 +21,24 @@ class PricesNamespace(BaseNamespace):
|
|
|
19
21
|
start: Timestamp,
|
|
20
22
|
end: Timestamp,
|
|
21
23
|
country: OneOrMany,
|
|
24
|
+
sequence: int = 1,
|
|
22
25
|
) -> pd.DataFrame:
|
|
23
26
|
"""Query day-ahead electricity prices.
|
|
24
27
|
|
|
28
|
+
The A44 document mixes several auctions. Only daily contracts are
|
|
29
|
+
kept (intraday auctions such as the Spanish IDAs are dropped), and
|
|
30
|
+
among them the one with the requested classification sequence.
|
|
31
|
+
|
|
25
32
|
Args:
|
|
26
33
|
start: Period start — date string or tz-aware Timestamp.
|
|
27
34
|
end: Period end — date string or tz-aware Timestamp.
|
|
28
35
|
country: Country code or list of codes (e.g., "FR" or ["FR", "ES"]).
|
|
29
36
|
When a list is passed, results include a ``country`` column.
|
|
37
|
+
sequence: Which day-ahead auction to return. ``1`` (default) is
|
|
38
|
+
the Single Day-Ahead Coupling (SDAC, gate closure 12:00 CET).
|
|
39
|
+
``2`` is the separate EXAA auction (10:15 CET) published for
|
|
40
|
+
DE_LU and AT. Zones that publish a single auction without a
|
|
41
|
+
sequence count as ``1``.
|
|
30
42
|
|
|
31
43
|
Returns:
|
|
32
44
|
DataFrame with columns: timestamp, value (EUR/MWh).
|
|
@@ -41,6 +53,13 @@ class PricesNamespace(BaseNamespace):
|
|
|
41
53
|
"out_Domain": area,
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
def _keep(ts: ET.Element) -> bool:
|
|
57
|
+
if timeseries_contract_type(ts) not in (None, "A01"):
|
|
58
|
+
return False
|
|
59
|
+
return (timeseries_sequence(ts) or 1) == sequence
|
|
60
|
+
|
|
44
61
|
return self._query_multi_cached(
|
|
45
62
|
"prices", "day_ahead", _params, country, start, end,
|
|
63
|
+
ts_filter=_keep,
|
|
64
|
+
partition_fn=lambda code: code if sequence == 1 else f"{code}.seq{sequence}",
|
|
46
65
|
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Offline tests for cache layout versioning."""
|
|
2
|
+
|
|
3
|
+
from entsoe.cache import CACHE_LAYOUT_VERSION, CacheConfig, CacheStore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_outdated_layouts_are_purged(tmp_path):
|
|
7
|
+
legacy = tmp_path / "prices" / "day_ahead" / "DE_LU"
|
|
8
|
+
legacy.mkdir(parents=True)
|
|
9
|
+
(legacy / "data.parquet").write_bytes(b"stale")
|
|
10
|
+
old_version = tmp_path / "v1" / "load"
|
|
11
|
+
old_version.mkdir(parents=True)
|
|
12
|
+
current = tmp_path / f"v{CACHE_LAYOUT_VERSION}" / "prices"
|
|
13
|
+
current.mkdir(parents=True)
|
|
14
|
+
unrelated = tmp_path / "notes.txt"
|
|
15
|
+
unrelated.write_text("keep")
|
|
16
|
+
|
|
17
|
+
store = CacheStore(CacheConfig(cache_dir=tmp_path))
|
|
18
|
+
|
|
19
|
+
assert not (tmp_path / "prices").exists()
|
|
20
|
+
assert not (tmp_path / "v1").exists()
|
|
21
|
+
assert current.exists()
|
|
22
|
+
assert unrelated.exists()
|
|
23
|
+
assert store.root == tmp_path / f"v{CACHE_LAYOUT_VERSION}"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Offline tests for day-ahead parsing: auction selection, A03 gaps, duplicates."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from entsoe._xml import parse_timeseries
|
|
7
|
+
from entsoe.namespaces.prices import PricesNamespace
|
|
8
|
+
|
|
9
|
+
NS = "urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:3"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _series(mrid, prices, *, contract="A01", sequence=None, start="2026-10-12T22:00Z",
|
|
13
|
+
end="2026-10-12T23:00Z"):
|
|
14
|
+
seq = (
|
|
15
|
+
"<classificationSequence_AttributeInstanceComponent.position>"
|
|
16
|
+
f"{sequence}</classificationSequence_AttributeInstanceComponent.position>"
|
|
17
|
+
if sequence is not None else ""
|
|
18
|
+
)
|
|
19
|
+
points = "".join(
|
|
20
|
+
f"<Point><position>{pos}</position><price.amount>{val}</price.amount></Point>"
|
|
21
|
+
for pos, val in prices.items()
|
|
22
|
+
)
|
|
23
|
+
return (
|
|
24
|
+
f"<TimeSeries><mRID>{mrid}</mRID><auction.type>A01</auction.type>"
|
|
25
|
+
f"<contract_MarketAgreement.type>{contract}</contract_MarketAgreement.type>"
|
|
26
|
+
"<currency_Unit.name>EUR</currency_Unit.name>"
|
|
27
|
+
f"<price_Measure_Unit.name>MWH</price_Measure_Unit.name>{seq}"
|
|
28
|
+
"<curveType>A03</curveType>"
|
|
29
|
+
f"<Period><timeInterval><start>{start}</start><end>{end}</end></timeInterval>"
|
|
30
|
+
f"<resolution>PT15M</resolution>{points}</Period></TimeSeries>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _doc(*series):
|
|
35
|
+
return f'<Publication_MarketDocument xmlns="{NS}">{"".join(series)}</Publication_MarketDocument>'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _FakeHttp:
|
|
39
|
+
def __init__(self, xml):
|
|
40
|
+
self.xml = xml
|
|
41
|
+
|
|
42
|
+
def query(self, params, start, end):
|
|
43
|
+
return self.xml
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _day_ahead(xml, **kwargs):
|
|
47
|
+
ns = PricesNamespace(_FakeHttp(xml), cache=None)
|
|
48
|
+
return ns.day_ahead("2026-10-13", "2026-10-14", country="DE_LU", **kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Shaped like the live DE_LU response: EXAA (sequence 2) listed before SDAC (1).
|
|
52
|
+
DE_LU = _doc(
|
|
53
|
+
_series(1, {1: 189.61, 2: 180.0, 3: 175.0, 4: 170.0}, sequence=2),
|
|
54
|
+
_series(2, {1: 191.89, 2: 185.0, 3: 178.0, 4: 172.0}, sequence=1),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Shaped like the live ES response: unsequenced SDAC plus IDA intraday auctions.
|
|
58
|
+
ES = _doc(
|
|
59
|
+
_series(1, {1: 220.0, 2: 210.0, 3: 205.0, 4: 200.0}),
|
|
60
|
+
_series(2, {1: 220.49, 2: 211.0, 3: 206.0, 4: 201.0}, contract="A07", sequence=1),
|
|
61
|
+
_series(3, {1: 219.0, 2: 209.0, 3: 204.0, 4: 199.0}, contract="A07", sequence=2),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_de_lu_defaults_to_sdac():
|
|
66
|
+
df = _day_ahead(DE_LU)
|
|
67
|
+
assert df["timestamp"].is_unique
|
|
68
|
+
assert df["value"].tolist() == [191.89, 185.0, 178.0, 172.0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_de_lu_sequence_2_is_exaa():
|
|
72
|
+
df = _day_ahead(DE_LU, sequence=2)
|
|
73
|
+
assert df["value"].tolist() == [189.61, 180.0, 175.0, 170.0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_spain_drops_intraday_auctions():
|
|
77
|
+
df = _day_ahead(ES)
|
|
78
|
+
assert df["value"].tolist() == [220.0, 210.0, 205.0, 200.0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_a03_omitted_point_repeats_previous_value():
|
|
82
|
+
# Position 3 omitted: it carries position 2's value. Trailing slot 4 too.
|
|
83
|
+
df = parse_timeseries(_doc(_series(1, {1: 10.0, 2: 20.0})))
|
|
84
|
+
assert len(df) == 4
|
|
85
|
+
assert df["value"].tolist() == [10.0, 20.0, 20.0, 20.0]
|
|
86
|
+
assert df["timestamp"].iloc[-1] == pd.Timestamp("2026-10-12T22:45Z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_verbatim_duplicate_series_dropped():
|
|
90
|
+
s = {1: 1.0, 2: 2.0, 3: 3.0, 4: 4.0}
|
|
91
|
+
df = parse_timeseries(_doc(_series(1, s), _series(1, s)))
|
|
92
|
+
assert len(df) == 4
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_unknown_sequence_raises_no_data():
|
|
96
|
+
from entsoe.exceptions import NoDataError
|
|
97
|
+
|
|
98
|
+
with pytest.raises(NoDataError):
|
|
99
|
+
_day_ahead(ES, sequence=2)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 3
|
|
3
3
|
requires-python = ">=3.10"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
|
@@ -1705,7 +1705,7 @@ wheels = [
|
|
|
1705
1705
|
|
|
1706
1706
|
[[package]]
|
|
1707
1707
|
name = "python-entsoe"
|
|
1708
|
-
version = "0.
|
|
1708
|
+
version = "0.6.2"
|
|
1709
1709
|
source = { editable = "." }
|
|
1710
1710
|
dependencies = [
|
|
1711
1711
|
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
name: Release Please
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: write
|
|
9
|
-
pull-requests: write
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
release-please:
|
|
13
|
-
runs-on: ubuntu-latest
|
|
14
|
-
outputs:
|
|
15
|
-
release_created: ${{ steps.release.outputs.release_created }}
|
|
16
|
-
steps:
|
|
17
|
-
- uses: googleapis/release-please-action@v4
|
|
18
|
-
id: release
|
|
19
|
-
with:
|
|
20
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
21
|
-
|
|
22
|
-
publish:
|
|
23
|
-
needs: release-please
|
|
24
|
-
if: needs.release-please.outputs.release_created == 'true'
|
|
25
|
-
runs-on: ubuntu-latest
|
|
26
|
-
permissions:
|
|
27
|
-
id-token: write
|
|
28
|
-
steps:
|
|
29
|
-
- uses: actions/checkout@v4
|
|
30
|
-
|
|
31
|
-
- uses: actions/setup-python@v5
|
|
32
|
-
with:
|
|
33
|
-
python-version: "3.12"
|
|
34
|
-
|
|
35
|
-
- name: Install build tools
|
|
36
|
-
run: pip install build
|
|
37
|
-
|
|
38
|
-
- name: Build package
|
|
39
|
-
run: python -m build
|
|
40
|
-
|
|
41
|
-
- name: Publish to PyPI
|
|
42
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"packages": {
|
|
3
|
-
".": {
|
|
4
|
-
"release-type": "python",
|
|
5
|
-
"package-name": "python-entsoe",
|
|
6
|
-
"extra-files": [
|
|
7
|
-
"pyproject.toml"
|
|
8
|
-
],
|
|
9
|
-
"changelog-sections": [
|
|
10
|
-
{ "type": "feat", "section": "Features" },
|
|
11
|
-
{ "type": "fix", "section": "Bug Fixes" },
|
|
12
|
-
{ "type": "perf", "section": "Performance" },
|
|
13
|
-
{ "type": "docs", "section": "Documentation", "hidden": true },
|
|
14
|
-
{ "type": "chore", "section": "Miscellaneous", "hidden": true }
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
19
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|