python-esios 2.2.0__tar.gz → 2.4.0__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 (84) hide show
  1. python_esios-2.4.0/.release-please-manifest.json +3 -0
  2. {python_esios-2.2.0 → python_esios-2.4.0}/CHANGELOG.md +19 -0
  3. {python_esios-2.2.0 → python_esios-2.4.0}/PKG-INFO +1 -1
  4. {python_esios-2.2.0 → python_esios-2.4.0}/pyproject.toml +7 -1
  5. python_esios-2.4.0/src/esios/constants.py +30 -0
  6. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/managers/indicators.py +124 -24
  7. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/processing/i90.py +32 -11
  8. python_esios-2.2.0/.release-please-manifest.json +0 -3
  9. python_esios-2.2.0/src/esios/constants.py +0 -20
  10. {python_esios-2.2.0 → python_esios-2.4.0}/.github/workflows/release-please.yml +0 -0
  11. {python_esios-2.2.0 → python_esios-2.4.0}/.gitignore +0 -0
  12. {python_esios-2.2.0 → python_esios-2.4.0}/CLAUDE.md +0 -0
  13. {python_esios-2.2.0 → python_esios-2.4.0}/LICENSE +0 -0
  14. {python_esios-2.2.0 → python_esios-2.4.0}/README.md +0 -0
  15. {python_esios-2.2.0 → python_esios-2.4.0}/examples/.gitignore +0 -0
  16. {python_esios-2.2.0 → python_esios-2.4.0}/examples/01_Quickstart/01_Setup and First Query.ipynb +0 -0
  17. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/01_Search Indicators.ipynb +0 -0
  18. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/02_Historical Data.ipynb +0 -0
  19. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/03_Multi-Geography Indicators.ipynb +0 -0
  20. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/04_Compare Indicators.ipynb +0 -0
  21. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/05_Market Prices.ipynb +0 -0
  22. {python_esios-2.2.0 → python_esios-2.4.0}/examples/02_Indicators/06_Generation and Demand.ipynb +0 -0
  23. {python_esios-2.2.0 → python_esios-2.4.0}/examples/03_Archives/01_Download Archives.ipynb +0 -0
  24. {python_esios-2.2.0 → python_esios-2.4.0}/examples/03_Archives/02_I90 Settlement Files.ipynb +0 -0
  25. {python_esios-2.2.0 → python_esios-2.4.0}/examples/04_Caching/01_Cache Management.ipynb +0 -0
  26. {python_esios-2.2.0 → python_esios-2.4.0}/examples/05_Advanced/01_Ad-hoc Pandas Expressions.ipynb +0 -0
  27. {python_esios-2.2.0 → python_esios-2.4.0}/examples/05_Advanced/02_Async Client.ipynb +0 -0
  28. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/01_Quickstart/01_Setup and First Query.yaml +0 -0
  29. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/01_Search Indicators.yaml +0 -0
  30. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/02_Historical Data.yaml +0 -0
  31. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/03_Multi-Geography Indicators.yaml +0 -0
  32. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/04_Compare Indicators.yaml +0 -0
  33. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/05_Market Prices.yaml +0 -0
  34. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/02_Indicators/06_Generation and Demand.yaml +0 -0
  35. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/03_Archives/01_Download Archives.yaml +0 -0
  36. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/03_Archives/02_I90 Settlement Files.yaml +0 -0
  37. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/04_Caching/01_Cache Management.yaml +0 -0
  38. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/05_Advanced/01_Ad-hoc Pandas Expressions.yaml +0 -0
  39. {python_esios-2.2.0 → python_esios-2.4.0}/examples/_specs/05_Advanced/02_Async Client.yaml +0 -0
  40. {python_esios-2.2.0 → python_esios-2.4.0}/examples/generate.py +0 -0
  41. {python_esios-2.2.0 → python_esios-2.4.0}/release-please-config.json +0 -0
  42. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/.agents/skills/esios/SKILL.md +0 -0
  43. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/__init__.py +0 -0
  44. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/async_client.py +0 -0
  45. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cache.py +0 -0
  46. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/catalog.py +0 -0
  47. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/__init__.py +0 -0
  48. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/app.py +0 -0
  49. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/archives.py +0 -0
  50. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/cache_cmd.py +0 -0
  51. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/catalog_cmd.py +0 -0
  52. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/config.py +0 -0
  53. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/config_cmd.py +0 -0
  54. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/exec_cmd.py +0 -0
  55. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/cli/indicators.py +0 -0
  56. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/client.py +0 -0
  57. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/__init__.py +0 -0
  58. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/archives.yaml +0 -0
  59. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/geos.yaml +0 -0
  60. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/indicators.yaml +0 -0
  61. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/magnitudes.yaml +0 -0
  62. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/data/time_periods.yaml +0 -0
  63. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/exceptions.py +0 -0
  64. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/managers/__init__.py +0 -0
  65. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/managers/archives.py +0 -0
  66. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/managers/base.py +0 -0
  67. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/managers/offer_indicators.py +0 -0
  68. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/models/__init__.py +0 -0
  69. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/models/archive.py +0 -0
  70. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/models/indicator.py +0 -0
  71. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/models/offer_indicator.py +0 -0
  72. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/processing/__init__.py +0 -0
  73. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/processing/dataframes.py +0 -0
  74. {python_esios-2.2.0 → python_esios-2.4.0}/src/esios/processing/zip.py +0 -0
  75. {python_esios-2.2.0 → python_esios-2.4.0}/tests/__init__.py +0 -0
  76. {python_esios-2.2.0 → python_esios-2.4.0}/tests/conftest.py +0 -0
  77. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_cache.py +0 -0
  78. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_client.py +0 -0
  79. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_dataframes.py +0 -0
  80. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_exceptions.py +0 -0
  81. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_i90.py +0 -0
  82. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_managers.py +0 -0
  83. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_models.py +0 -0
  84. {python_esios-2.2.0 → python_esios-2.4.0}/tests/test_zip.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.0"
3
+ }
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0](https://github.com/datons/python-esios/compare/python-esios-v2.3.0...python-esios-v2.4.0) (2026-03-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * concurrent & adaptive chunk fetching for indicators ([1ef2590](https://github.com/datons/python-esios/commit/1ef2590cf870e13e57792cbb734e10f8d6449744))
9
+
10
+ ## [2.3.0](https://github.com/datons/python-esios/compare/python-esios-v2.2.0...python-esios-v2.3.0) (2026-03-16)
11
+
12
+
13
+ ### Features
14
+
15
+ * restore column_name parameter in historical() ([a967b6f](https://github.com/datons/python-esios/commit/a967b6f54cd4e84d18469d99f0d4e03665b73e81))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * handle DST transitions in I90 datetime parsing ([aa1deef](https://github.com/datons/python-esios/commit/aa1deefd442f2c53a376a4f3773b6fffea562121))
21
+
3
22
  ## [2.2.0](https://github.com/datons/python-esios/compare/python-esios-v2.1.0...python-esios-v2.2.0) (2026-03-04)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-esios
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: A Python wrapper for the ESIOS API (Spanish electricity market)
5
5
  Project-URL: Homepage, https://github.com/datons/python-esios
6
6
  Project-URL: Repository, https://github.com/datons/python-esios
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-esios"
7
- version = "2.2.0"
7
+ version = "2.4.0"
8
8
  description = "A Python wrapper for the ESIOS API (Spanish electricity market)"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-only"
@@ -53,3 +53,9 @@ packages = ["src/esios"]
53
53
  [tool.pytest.ini_options]
54
54
  testpaths = ["tests"]
55
55
  pythonpath = ["src"]
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "kaleido>=1.2.0",
60
+ "plotly>=6.6.0",
61
+ ]
@@ -0,0 +1,30 @@
1
+ """Constants for the ESIOS API client."""
2
+
3
+ ESIOS_API_URL = "https://api.esios.ree.es"
4
+
5
+ DEFAULT_HEADERS = {
6
+ "Accept": "application/json; application/vnd.esios-api-v1+json",
7
+ "Content-Type": "application/json",
8
+ "Host": "api.esios.ree.es",
9
+ }
10
+
11
+ DEFAULT_TIMEOUT = 30.0 # seconds
12
+
13
+ MAX_RETRIES = 3
14
+ RETRY_MIN_WAIT = 2 # seconds
15
+ RETRY_MAX_WAIT = 10 # seconds
16
+
17
+ # ESIOS API chunk sizes for historical data fetching.
18
+ # High-geo indicators (40+ geos) timeout (504) at >21 days.
19
+ # Low-geo indicators handle 6+ months per request in <0.1s.
20
+ CHUNK_SIZE_DAYS = 21 # Legacy default, kept for backward compat
21
+ CHUNK_SIZE_DAYS_LOW_GEO = 180 # 6 months for indicators with few geos
22
+ CHUNK_SIZE_DAYS_HIGH_GEO = 21 # Conservative for indicators with many geos
23
+ HIGH_GEO_THRESHOLD = 15 # Indicators with >= this many geos use smaller chunks
24
+
25
+ # Concurrent chunk fetching within a single indicator.
26
+ # 4 workers gives ~17-95x speedup over sequential with no errors.
27
+ # Diminishing returns past 4 (ESIOS server becomes the bottleneck).
28
+ DEFAULT_CHUNK_WORKERS = 4
29
+
30
+ TIMEZONE = "Europe/Madrid"
@@ -3,13 +3,20 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
7
  from datetime import timedelta
7
8
  from typing import Any
8
9
 
9
10
  import pandas as pd
10
11
 
11
12
  from esios.cache import CacheStore
12
- from esios.constants import CHUNK_SIZE_DAYS, TIMEZONE
13
+ from esios.constants import (
14
+ CHUNK_SIZE_DAYS_HIGH_GEO,
15
+ CHUNK_SIZE_DAYS_LOW_GEO,
16
+ DEFAULT_CHUNK_WORKERS,
17
+ HIGH_GEO_THRESHOLD,
18
+ TIMEZONE,
19
+ )
13
20
  from esios.managers.base import BaseManager
14
21
  from esios.models.indicator import Indicator
15
22
  from esios.processing.dataframes import to_dataframe
@@ -131,6 +138,88 @@ class IndicatorHandle:
131
138
  f"Available: {', '.join(available)}"
132
139
  )
133
140
 
141
+ @property
142
+ def _chunk_days(self) -> int:
143
+ """Choose chunk size based on indicator's geo count.
144
+
145
+ ESIOS API times out (504) for high-geo indicators (40+ geos) with
146
+ windows larger than ~3 weeks. Low-geo indicators handle 6+ months
147
+ per request in <0.1s.
148
+
149
+ When geos are unknown (empty metadata), uses the conservative
150
+ chunk size to avoid timeouts on first fetch.
151
+ """
152
+ geo_count = len(self.geos)
153
+ if geo_count == 0:
154
+ # Unknown geo count — be conservative
155
+ return CHUNK_SIZE_DAYS_HIGH_GEO
156
+ if geo_count >= HIGH_GEO_THRESHOLD:
157
+ return CHUNK_SIZE_DAYS_HIGH_GEO
158
+ return CHUNK_SIZE_DAYS_LOW_GEO
159
+
160
+ def _fetch_one(
161
+ self, start: str, end: str, base_params: dict[str, Any],
162
+ ) -> list[dict]:
163
+ """Fetch a single date-range chunk from the ESIOS API."""
164
+ params = {
165
+ **base_params,
166
+ "start_date": start,
167
+ "end_date": end + "T23:59:59",
168
+ }
169
+ logger.debug("Fetch %s → %s", start, end)
170
+ data = self._manager._get(f"indicators/{self.id}", params=params)
171
+ return data.get("indicator", {}).get("values", [])
172
+
173
+ def _fetch_chunks(
174
+ self,
175
+ gaps: list,
176
+ base_params: dict[str, Any],
177
+ max_workers: int = DEFAULT_CHUNK_WORKERS,
178
+ ) -> list[dict]:
179
+ """Fetch all gap chunks concurrently, return values in order.
180
+
181
+ Builds a list of (start, end) chunks from the gaps, then fetches
182
+ them in parallel using a thread pool. Results are reassembled in
183
+ chronological order.
184
+ """
185
+ chunk_delta = timedelta(days=self._chunk_days)
186
+
187
+ # Build chunk list
188
+ chunks: list[tuple[str, str]] = []
189
+ for gap in gaps:
190
+ current = gap.start
191
+ while current <= gap.end:
192
+ chunk_end = min(current + chunk_delta, gap.end)
193
+ chunks.append((
194
+ current.strftime("%Y-%m-%d"),
195
+ chunk_end.strftime("%Y-%m-%d"),
196
+ ))
197
+ current = chunk_end + timedelta(days=1)
198
+
199
+ if not chunks:
200
+ return []
201
+
202
+ if len(chunks) == 1:
203
+ return self._fetch_one(chunks[0][0], chunks[0][1], base_params)
204
+
205
+ # Fetch concurrently, preserve order
206
+ results: list[list[dict] | None] = [None] * len(chunks)
207
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
208
+ futures = {
209
+ pool.submit(self._fetch_one, s, e, base_params): i
210
+ for i, (s, e) in enumerate(chunks)
211
+ }
212
+ for future in as_completed(futures):
213
+ idx = futures[future]
214
+ results[idx] = future.result()
215
+
216
+ # Flatten in chronological order
217
+ all_values: list[dict] = []
218
+ for chunk_values in results:
219
+ if chunk_values:
220
+ all_values.extend(chunk_values)
221
+ return all_values
222
+
134
223
  def historical(
135
224
  self,
136
225
  start: str,
@@ -142,15 +231,28 @@ class IndicatorHandle:
142
231
  geo_agg: str | None = None,
143
232
  time_trunc: str | None = None,
144
233
  geo_trunc: str | None = None,
234
+ column_name: str | None = None,
235
+ chunk_workers: int = DEFAULT_CHUNK_WORKERS,
145
236
  ) -> pd.DataFrame:
146
237
  """Fetch historical values as a DataFrame with DatetimeIndex.
147
238
 
148
239
  Uses local parquet cache when enabled. Only fetches missing date ranges
149
- from the API. Automatically chunks requests exceeding ~3 weeks.
240
+ from the API. Automatically chunks requests and fetches concurrently.
241
+
242
+ Chunk size adapts to the indicator's geo count: 180 days for low-geo
243
+ indicators, 21 days for high-geo (≥15 geos) to avoid ESIOS timeouts.
150
244
 
151
245
  When multiple geo_ids are present (e.g. indicator 600 returns data for
152
246
  several countries), the result is pivoted so each geo becomes a column
153
247
  named by its geo_name. Use *geo_ids* to filter to specific geos.
248
+
249
+ Args:
250
+ column_name: If provided, rename the output column(s) to this name.
251
+ Useful for single-column results where a stable name like
252
+ ``"value"`` is preferred over the default geo_name or
253
+ indicator ID.
254
+ chunk_workers: Number of concurrent threads for fetching chunks.
255
+ Defaults to 4. Set to 1 for sequential fetching.
154
256
  """
155
257
  base_params: dict[str, Any] = {
156
258
  "locale": locale,
@@ -204,24 +306,8 @@ class IndicatorHandle:
204
306
  from esios.cache import DateRange
205
307
  gaps = [DateRange(start_date, end_date)]
206
308
 
207
- # -- Fetch missing ranges ----------------------------------------------
208
- all_values: list[dict] = []
209
- chunk_delta = timedelta(days=CHUNK_SIZE_DAYS)
210
-
211
- for gap in gaps:
212
- current = gap.start
213
- gap_end = gap.end
214
- while current <= gap_end:
215
- chunk_end = min(current + chunk_delta, gap_end)
216
- params = {
217
- **base_params,
218
- "start_date": current.strftime("%Y-%m-%d"),
219
- "end_date": chunk_end.strftime("%Y-%m-%d") + "T23:59:59",
220
- }
221
- logger.debug("Fetch %s → %s", params["start_date"], params["end_date"])
222
- data = self._manager._get(f"indicators/{self.id}", params=params)
223
- all_values.extend(data.get("indicator", {}).get("values", []))
224
- current = chunk_end + timedelta(days=1)
309
+ # -- Fetch missing ranges (concurrent + adaptive chunk size) -----------
310
+ all_values = self._fetch_chunks(gaps, base_params, max_workers=chunk_workers)
225
311
 
226
312
  # Learn any new geo mappings from the response
227
313
  self._enrich_geo_map(all_values)
@@ -250,7 +336,7 @@ class IndicatorHandle:
250
336
  if existing:
251
337
  result = result[existing]
252
338
 
253
- return self._finalize(result)
339
+ return self._finalize(result, column_name=column_name)
254
340
 
255
341
  def _to_wide(self, values: list[dict]) -> pd.DataFrame:
256
342
  """Convert raw API value dicts to wide-format DataFrame.
@@ -283,16 +369,26 @@ class IndicatorHandle:
283
369
  df = df.drop(columns=geo_drop, errors="ignore")
284
370
  return df
285
371
 
286
- def _finalize(self, df: pd.DataFrame) -> pd.DataFrame:
372
+ def _finalize(
373
+ self, df: pd.DataFrame, *, column_name: str | None = None,
374
+ ) -> pd.DataFrame:
287
375
  """Prepare DataFrame for user-facing output.
288
376
 
289
377
  Cache stores columns as str(geo_id). This method renames them to
290
378
  human-readable geo_names at the very end, just before returning to
291
379
  the caller. Single-value/single-geo indicators get the indicator ID.
380
+
381
+ If ``column_name`` is provided and the result has a single column,
382
+ that column is renamed to ``column_name`` (e.g. ``"value"``).
292
383
  """
293
384
  if df.empty:
294
385
  return df
295
386
 
387
+ # If caller wants a specific column name and there's a single column, use it
388
+ if column_name and len(df.columns) == 1:
389
+ df = df.rename(columns={df.columns[0]: column_name})
390
+ return df
391
+
296
392
  if len(df.columns) == 1:
297
393
  col = df.columns[0]
298
394
  if col == "value":
@@ -305,11 +401,15 @@ class IndicatorHandle:
305
401
  if rename:
306
402
  df = df.rename(columns=rename)
307
403
 
404
+ # If caller wants a specific column name for multi-column, skip
405
+ # (ambiguous which column to rename)
406
+ if column_name and len(df.columns) == 1:
407
+ df = df.rename(columns={df.columns[0]: column_name})
408
+ return df
409
+
308
410
  # Single-geo after rename: use indicator ID as column name
309
411
  if len(df.columns) == 1:
310
412
  col = df.columns[0]
311
- # If the single column is a geo_name, keep it (user filtered to one geo)
312
- # If it's still a geo_id string, rename to indicator ID
313
413
  if col not in geo_map.values():
314
414
  df = df.rename(columns={col: str(self.id)})
315
415
 
@@ -166,11 +166,15 @@ class I90Sheet:
166
166
  def _normalize_datetime_columns(self, columns: np.ndarray) -> np.ndarray:
167
167
  """Normalize time column headers to integer period indices.
168
168
 
169
- Handles three column formats found in I90 files:
170
- - Sequential integers 1–24 (hourly) or 1–96 (quarterly)
171
- - H-Q format with dash notation: "1-1", "1-2", "1-3", "1-4", "2-1", …
172
- - NaN-filler format: [1, NaN, NaN, NaN, 2, …] (one label per hour,
173
- three trailing NaNs for quarters 2–4)
169
+ Handles four column formats found in I90 files:
170
+
171
+ 1. Sequential integers: 1–24 (hourly) or 1–96 (quarterly)
172
+ 2. H-Q format: "1-1", "1-2", "1-3", "1-4", "2-1", …
173
+ 3. NaN-filler format: [1, NaN, NaN, NaN, 2, …]
174
+ 4. Range format (DST days): "00-01", "01-02", "02-03a", "02-03b", …
175
+ where the first number is the start hour and a/b suffix marks
176
+ the repeated hour on fall-back days. Detected by the first
177
+ column starting with "0" (e.g. "00-01").
174
178
  """
175
179
  if any(pd.isna(columns)):
176
180
  self._n_columns_totals = 3
@@ -178,6 +182,17 @@ class I90Sheet:
178
182
  self._n_columns_totals = 2
179
183
 
180
184
  series = pd.Series(columns, dtype=str).ffill()
185
+
186
+ # Range format (DST): "00-01", "01-02", "02-03a", "02-03b", ...
187
+ # Detected by first column starting with "0" (sequential ints start at 1).
188
+ first_val = str(columns[0]).strip()
189
+ if first_val.startswith("0") and "-" in first_val:
190
+ # Simply assign sequential 1-based indices.
191
+ # The count of columns (23, 24, or 25 for hourly; 92, 96, or 100
192
+ # for QH) already encodes the DST information. The datetime builder
193
+ # in _preprocess uses these as offsets from midnight UTC.
194
+ return np.arange(1, len(columns) + 1)
195
+
181
196
  parts = series.str.split("-")
182
197
  hours = parts.str[0].astype(float).astype(int)
183
198
 
@@ -251,12 +266,18 @@ class I90Sheet:
251
266
  self.frequency = "hourly"
252
267
  time_deltas = columns_date * 60 # minutes
253
268
 
254
- # Build datetime index
255
- base_date = pd.to_datetime(self.metadata["date_data"])
256
- columns_datetime = base_date + pd.to_timedelta(time_deltas, unit="m")
257
- columns_datetime = pd.DatetimeIndex(columns_datetime).tz_localize(
258
- "Europe/Madrid", ambiguous="infer"
259
- )
269
+ # Build datetime index in UTC to avoid DST ambiguity.
270
+ # On fall-back days (Oct), I90 has 25 hourly periods (or 100 QH).
271
+ # Naïve offset arithmetic creates a single 02:00 that tz_localize
272
+ # cannot disambiguate. By anchoring midnight in Europe/Madrid,
273
+ # converting to UTC, then adding offsets, each period maps to a
274
+ # unique UTC instant — no ambiguity.
275
+ # On spring-forward days (Mar), I90 has 23 periods (or 92 QH)
276
+ # and this approach naturally skips the non-existent hour.
277
+ midnight_utc = pd.Timestamp(
278
+ self.metadata["date_data"], tz="Europe/Madrid"
279
+ ).tz_convert("UTC")
280
+ columns_datetime = midnight_utc + pd.to_timedelta(time_deltas, unit="m")
260
281
 
261
282
  data = pd.DataFrame(self.rows[idx + 1 :], columns=columns)
262
283
 
@@ -1,3 +0,0 @@
1
- {
2
- ".": "2.2.0"
3
- }
@@ -1,20 +0,0 @@
1
- """Constants for the ESIOS API client."""
2
-
3
- ESIOS_API_URL = "https://api.esios.ree.es"
4
-
5
- DEFAULT_HEADERS = {
6
- "Accept": "application/json; application/vnd.esios-api-v1+json",
7
- "Content-Type": "application/json",
8
- "Host": "api.esios.ree.es",
9
- }
10
-
11
- DEFAULT_TIMEOUT = 30.0 # seconds
12
-
13
- MAX_RETRIES = 3
14
- RETRY_MIN_WAIT = 2 # seconds
15
- RETRY_MAX_WAIT = 10 # seconds
16
-
17
- # ESIOS API limits responses to ~3 weeks of data per request
18
- CHUNK_SIZE_DAYS = 21
19
-
20
- TIMEZONE = "Europe/Madrid"
File without changes
File without changes
File without changes
File without changes