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.
Files changed (64) hide show
  1. python_entsoe-0.6.2/.github/workflows/publish.yml +26 -0
  2. python_entsoe-0.6.2/.gitlab-ci.yml +22 -0
  3. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/CHANGELOG.md +17 -0
  4. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/CLAUDE.md +8 -4
  5. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/PKG-INFO +11 -9
  6. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/README.md +5 -2
  7. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/docs/data-availability.md +24 -0
  8. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/pyproject.toml +5 -9
  9. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_mappings.py +0 -1
  10. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_xml.py +56 -7
  11. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cache.py +41 -10
  12. python_entsoe-0.6.2/src/entsoe/catalog.py +228 -0
  13. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/prices.py +2 -1
  14. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/client.py +0 -3
  15. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/_base.py +26 -14
  16. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/prices.py +20 -1
  17. python_entsoe-0.6.2/tests/test_cache_layout.py +23 -0
  18. python_entsoe-0.6.2/tests/test_xml_day_ahead.py +99 -0
  19. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/uv.lock +2 -4
  20. python_entsoe-0.6.0/.github/workflows/publish.yml +0 -42
  21. python_entsoe-0.6.0/.release-please-manifest.json +0 -3
  22. python_entsoe-0.6.0/release-please-config.json +0 -19
  23. python_entsoe-0.6.0/src/entsoe/catalog.py +0 -51
  24. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/.gitignore +0 -0
  25. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/.python-version +0 -0
  26. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/balancing.ipynb +0 -0
  27. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/generation.ipynb +0 -0
  28. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/generation_per_plant.ipynb +0 -0
  29. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/load.ipynb +0 -0
  30. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/prices.ipynb +0 -0
  31. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/examples/transmission.ipynb +0 -0
  32. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/scripts/generate_notebooks.py +0 -0
  33. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/.agents/skills/entsoe/SKILL.md +0 -0
  34. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/__init__.py +0 -0
  35. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/_http.py +0 -0
  36. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/__init__.py +0 -0
  37. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/_output.py +0 -0
  38. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/_resolve.py +0 -0
  39. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/app.py +0 -0
  40. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/balancing.py +0 -0
  41. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/cache_cmd.py +0 -0
  42. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/catalog_cmd.py +0 -0
  43. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/config.py +0 -0
  44. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/config_cmd.py +0 -0
  45. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/exec_cmd.py +0 -0
  46. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/generation.py +0 -0
  47. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/load.py +0 -0
  48. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/cli/transmission.py +0 -0
  49. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/exceptions.py +0 -0
  50. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/__init__.py +0 -0
  51. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/balancing.py +0 -0
  52. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/generation.py +0 -0
  53. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/load.py +0 -0
  54. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/namespaces/transmission.py +0 -0
  55. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/src/entsoe/py.typed +0 -0
  56. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/__init__.py +0 -0
  57. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/conftest.py +0 -0
  58. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_balancing.py +0 -0
  59. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_generation.py +0 -0
  60. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_load.py +0 -0
  61. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_multi.py +0 -0
  62. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_prices.py +0 -0
  63. {python_entsoe-0.6.0 → python_entsoe-0.6.2}/tests/test_transmission.py +0 -0
  64. {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
- ## PyPI Publishing
12
+ ## Publishing
13
13
 
14
- Publishing is **tag-based only**. Pushing to `main` does NOT trigger a release.
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.3.0
20
+ git tag v0.6.2
21
21
  git push origin main --tags
22
22
  ```
23
23
 
24
- The GitHub Actions workflow (`.github/workflows/publish.yml`) uses OIDC trusted publishing — no tokens needed.
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-entsoe
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Python client for the ENTSO-E Transparency Platform API
5
- Project-URL: Homepage, https://github.com/datons/python-entsoe
6
- Project-URL: Repository, https://github.com/datons/python-entsoe
7
- Project-URL: Issues, https://github.com/datons/python-entsoe/issues
8
- Project-URL: Changelog, https://github.com/datons/python-entsoe/releases
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 https://github.com/jsulopzs/python-entsoe.git
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 https://github.com/jsulopzs/python-entsoe.git
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.0"
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://github.com/datons/python-entsoe"
37
- Repository = "https://github.com/datons/python-entsoe"
38
- Issues = "https://github.com/datons/python-entsoe/issues"
39
- Changelog = "https://github.com/datons/python-entsoe/releases"
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
- def parse_timeseries(xml_text: str) -> pd.DataFrame:
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
- position = int(position_text)
172
- value = _extract_point_value(point)
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: {cache_dir}/{domain}/{topic}/{partition}/data.parquet"""
79
- return self.config.cache_dir / domain / topic / partition / "data.parquet"
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: {cache_dir}/{domain}/{topic}/{partition}/meta.json"""
83
- return self.config.cache_dir / domain / topic / partition / "meta.json"
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.config.cache_dir / domain / topic / partition
325
+ target = self.root / domain / topic / partition
295
326
  elif domain and topic:
296
- target = self.config.cache_dir / domain / topic
327
+ target = self.root / domain / topic
297
328
  elif domain:
298
- target = self.config.cache_dir / domain
329
+ target = self.root / domain
299
330
  else:
300
- target = self.config.cache_dir
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.config.cache_dir
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