python-entsoe 0.6.0__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.0 → python_entsoe-0.6.2}/CHANGELOG.md +17 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/CLAUDE.md +8 -4
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/PKG-INFO +11 -9
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/README.md +5 -2
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/docs/data-availability.md +24 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/pyproject.toml +5 -9
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_mappings.py +0 -1
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_xml.py +56 -7
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cache.py +41 -10
- python_entsoe-0.6.2/src/entsoe/catalog.py +228 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/prices.py +2 -1
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/client.py +0 -3
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/_base.py +26 -14
- {python_entsoe-0.6.0 → 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.0 → python_entsoe-0.6.2}/uv.lock +2 -4
- python_entsoe-0.6.0/.github/workflows/publish.yml +0 -42
- python_entsoe-0.6.0/.release-please-manifest.json +0 -3
- python_entsoe-0.6.0/release-please-config.json +0 -19
- python_entsoe-0.6.0/src/entsoe/catalog.py +0 -51
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/.gitignore +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/.python-version +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/balancing.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/generation.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/generation_per_plant.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/load.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/prices.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/transmission.ipynb +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/scripts/generate_notebooks.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/.agents/skills/entsoe/SKILL.md +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/__init__.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_http.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/__init__.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/_output.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/_resolve.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/app.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/balancing.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/cache_cmd.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/catalog_cmd.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/config.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/config_cmd.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/exec_cmd.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/generation.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/load.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/transmission.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/exceptions.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/__init__.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/balancing.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/generation.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/load.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/transmission.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/py.typed +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/__init__.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/conftest.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_balancing.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_generation.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_load.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_multi.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_prices.py +0 -0
- {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_transmission.py +0 -0
- {python_entsoe-0.6.0 → 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,22 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.6.1](https://github.com/datons/python-entsoe/compare/python-entsoe-v0.6.0...python-entsoe-v0.6.1) (2026-05-16)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* restore catalog imports broken by incomplete refactor in dcc35f7 ([74c0814](https://github.com/datons/python-entsoe/commit/74c0814bbdd3bd8d43981e77a1ab573ddf80a483))
|
|
19
|
+
|
|
3
20
|
## [0.6.0](https://github.com/datons/python-entsoe/compare/python-entsoe-v0.5.0...python-entsoe-v0.6.0) (2026-03-04)
|
|
4
21
|
|
|
5
22
|
|
|
@@ -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
|
|
@@ -21,7 +21,6 @@ Classifier: Topic :: Scientific/Engineering
|
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: pandas>=2.0
|
|
23
23
|
Requires-Dist: pyarrow>=23.0.1
|
|
24
|
-
Requires-Dist: pyyaml>=6.0
|
|
25
24
|
Requires-Dist: requests>=2.28
|
|
26
25
|
Requires-Dist: rich>=13.0
|
|
27
26
|
Requires-Dist: typer>=0.9
|
|
@@ -94,11 +93,14 @@ df = client.load.forecast(start, end, country="FR")
|
|
|
94
93
|
|
|
95
94
|
| Method | Description | Parameters |
|
|
96
95
|
|--------|-------------|------------|
|
|
97
|
-
| `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 |
|
|
98
97
|
|
|
99
98
|
```python
|
|
100
99
|
df = client.prices.day_ahead(start, end, country="FR")
|
|
101
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)
|
|
102
104
|
```
|
|
103
105
|
|
|
104
106
|
### `client.generation`
|
|
@@ -252,7 +254,7 @@ See the [`examples/`](examples/) directory for Jupyter notebooks with plotly vis
|
|
|
252
254
|
## Development
|
|
253
255
|
|
|
254
256
|
```bash
|
|
255
|
-
git clone
|
|
257
|
+
git clone git@gitlab.com:datons.com/libraries/python-entsoe.git
|
|
256
258
|
cd python-entsoe
|
|
257
259
|
uv sync
|
|
258
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"
|
|
@@ -22,7 +22,6 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
24
|
"pandas>=2.0",
|
|
25
|
-
"pyyaml>=6.0",
|
|
26
25
|
"requests>=2.28",
|
|
27
26
|
"typer>=0.9",
|
|
28
27
|
"rich>=13.0",
|
|
@@ -33,10 +32,10 @@ dependencies = [
|
|
|
33
32
|
entsoe = "entsoe.cli:app"
|
|
34
33
|
|
|
35
34
|
[project.urls]
|
|
36
|
-
Homepage = "https://
|
|
37
|
-
Repository = "https://
|
|
38
|
-
Issues = "https://
|
|
39
|
-
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"
|
|
40
39
|
|
|
41
40
|
[build-system]
|
|
42
41
|
requires = ["hatchling"]
|
|
@@ -47,9 +46,6 @@ packages = ["src/entsoe"]
|
|
|
47
46
|
|
|
48
47
|
[tool.pytest.ini_options]
|
|
49
48
|
testpaths = ["tests"]
|
|
50
|
-
markers = [
|
|
51
|
-
"integration: tests that hit real APIs (deselect with -m 'not integration')",
|
|
52
|
-
]
|
|
53
49
|
|
|
54
50
|
[dependency-groups]
|
|
55
51
|
dev = [
|
|
@@ -399,7 +399,6 @@ NEIGHBOURS: dict[str, set[str]] = {
|
|
|
399
399
|
"BG": {"RO", "RS", "MK", "GR", "TR"},
|
|
400
400
|
"MK": {"RS", "BG", "GR", "AL"},
|
|
401
401
|
"GR": {"BG", "MK", "AL", "IT_SUD", "TR"},
|
|
402
|
-
"IT_SUD": {"GR"},
|
|
403
402
|
"AL": {"RS", "MK", "GR", "ME"},
|
|
404
403
|
"ME": {"RS", "BA", "AL"},
|
|
405
404
|
"TR": {"BG", "GR"},
|
|
@@ -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
|
|