python-esios 2.3.0__tar.gz → 2.4.1__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 (85) hide show
  1. python_esios-2.4.1/.release-please-manifest.json +3 -0
  2. {python_esios-2.3.0 → python_esios-2.4.1}/CHANGELOG.md +15 -0
  3. {python_esios-2.3.0 → python_esios-2.4.1}/PKG-INFO +4 -4
  4. {python_esios-2.3.0 → python_esios-2.4.1}/README.md +3 -3
  5. {python_esios-2.3.0 → python_esios-2.4.1}/pyproject.toml +7 -1
  6. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/.agents/skills/esios/SKILL.md +2 -2
  7. python_esios-2.4.1/src/esios/constants.py +30 -0
  8. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/managers/indicators.py +99 -20
  9. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/processing/i90.py +43 -0
  10. {python_esios-2.3.0 → python_esios-2.4.1}/tests/conftest.py +12 -12
  11. python_esios-2.4.1/tests/test_i90.py +319 -0
  12. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_managers.py +10 -10
  13. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_models.py +2 -2
  14. python_esios-2.3.0/.release-please-manifest.json +0 -3
  15. python_esios-2.3.0/src/esios/constants.py +0 -20
  16. python_esios-2.3.0/tests/test_i90.py +0 -167
  17. {python_esios-2.3.0 → python_esios-2.4.1}/.github/workflows/release-please.yml +0 -0
  18. {python_esios-2.3.0 → python_esios-2.4.1}/.gitignore +0 -0
  19. {python_esios-2.3.0 → python_esios-2.4.1}/CLAUDE.md +0 -0
  20. {python_esios-2.3.0 → python_esios-2.4.1}/LICENSE +0 -0
  21. {python_esios-2.3.0 → python_esios-2.4.1}/examples/.gitignore +0 -0
  22. {python_esios-2.3.0 → python_esios-2.4.1}/examples/01_Quickstart/01_Setup and First Query.ipynb +0 -0
  23. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/01_Search Indicators.ipynb +0 -0
  24. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/02_Historical Data.ipynb +0 -0
  25. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/03_Multi-Geography Indicators.ipynb +0 -0
  26. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/04_Compare Indicators.ipynb +0 -0
  27. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/05_Market Prices.ipynb +0 -0
  28. {python_esios-2.3.0 → python_esios-2.4.1}/examples/02_Indicators/06_Generation and Demand.ipynb +0 -0
  29. {python_esios-2.3.0 → python_esios-2.4.1}/examples/03_Archives/01_Download Archives.ipynb +0 -0
  30. {python_esios-2.3.0 → python_esios-2.4.1}/examples/03_Archives/02_I90 Settlement Files.ipynb +0 -0
  31. {python_esios-2.3.0 → python_esios-2.4.1}/examples/04_Caching/01_Cache Management.ipynb +0 -0
  32. {python_esios-2.3.0 → python_esios-2.4.1}/examples/05_Advanced/01_Ad-hoc Pandas Expressions.ipynb +0 -0
  33. {python_esios-2.3.0 → python_esios-2.4.1}/examples/05_Advanced/02_Async Client.ipynb +0 -0
  34. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/01_Quickstart/01_Setup and First Query.yaml +0 -0
  35. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/01_Search Indicators.yaml +0 -0
  36. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/02_Historical Data.yaml +0 -0
  37. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/03_Multi-Geography Indicators.yaml +0 -0
  38. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/04_Compare Indicators.yaml +0 -0
  39. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/05_Market Prices.yaml +0 -0
  40. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/02_Indicators/06_Generation and Demand.yaml +0 -0
  41. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/03_Archives/01_Download Archives.yaml +0 -0
  42. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/03_Archives/02_I90 Settlement Files.yaml +0 -0
  43. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/04_Caching/01_Cache Management.yaml +0 -0
  44. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/05_Advanced/01_Ad-hoc Pandas Expressions.yaml +0 -0
  45. {python_esios-2.3.0 → python_esios-2.4.1}/examples/_specs/05_Advanced/02_Async Client.yaml +0 -0
  46. {python_esios-2.3.0 → python_esios-2.4.1}/examples/generate.py +0 -0
  47. {python_esios-2.3.0 → python_esios-2.4.1}/release-please-config.json +0 -0
  48. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/__init__.py +0 -0
  49. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/async_client.py +0 -0
  50. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cache.py +0 -0
  51. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/catalog.py +0 -0
  52. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/__init__.py +0 -0
  53. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/app.py +0 -0
  54. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/archives.py +0 -0
  55. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/cache_cmd.py +0 -0
  56. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/catalog_cmd.py +0 -0
  57. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/config.py +0 -0
  58. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/config_cmd.py +0 -0
  59. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/exec_cmd.py +0 -0
  60. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/cli/indicators.py +0 -0
  61. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/client.py +0 -0
  62. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/__init__.py +0 -0
  63. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/archives.yaml +0 -0
  64. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/geos.yaml +0 -0
  65. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/indicators.yaml +0 -0
  66. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/magnitudes.yaml +0 -0
  67. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/data/time_periods.yaml +0 -0
  68. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/exceptions.py +0 -0
  69. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/managers/__init__.py +0 -0
  70. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/managers/archives.py +0 -0
  71. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/managers/base.py +0 -0
  72. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/managers/offer_indicators.py +0 -0
  73. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/models/__init__.py +0 -0
  74. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/models/archive.py +0 -0
  75. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/models/indicator.py +0 -0
  76. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/models/offer_indicator.py +0 -0
  77. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/processing/__init__.py +0 -0
  78. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/processing/dataframes.py +0 -0
  79. {python_esios-2.3.0 → python_esios-2.4.1}/src/esios/processing/zip.py +0 -0
  80. {python_esios-2.3.0 → python_esios-2.4.1}/tests/__init__.py +0 -0
  81. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_cache.py +0 -0
  82. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_client.py +0 -0
  83. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_dataframes.py +0 -0
  84. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_exceptions.py +0 -0
  85. {python_esios-2.3.0 → python_esios-2.4.1}/tests/test_zip.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.1"
3
+ }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.1](https://github.com/datons/python-esios/compare/python-esios-v2.4.0...python-esios-v2.4.1) (2026-05-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **i90:** detect separator columns dynamically by header label ([1a9bf77](https://github.com/datons/python-esios/commit/1a9bf778eeb769aac836a945787c951c624f3806))
9
+ * **i90:** detect separator columns dynamically by header label ([d6c747f](https://github.com/datons/python-esios/commit/d6c747f4db44b5667abf12f075ef468eca3eec20))
10
+
11
+ ## [2.4.0](https://github.com/datons/python-esios/compare/python-esios-v2.3.0...python-esios-v2.4.0) (2026-03-23)
12
+
13
+
14
+ ### Features
15
+
16
+ * concurrent & adaptive chunk fetching for indicators ([1ef2590](https://github.com/datons/python-esios/commit/1ef2590cf870e13e57792cbb734e10f8d6449744))
17
+
3
18
  ## [2.3.0](https://github.com/datons/python-esios/compare/python-esios-v2.2.0...python-esios-v2.3.0) (2026-03-16)
4
19
 
5
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-esios
3
- Version: 2.3.0
3
+ Version: 2.4.1
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
@@ -85,7 +85,7 @@ from esios import ESIOSClient
85
85
  client = ESIOSClient()
86
86
 
87
87
  # Get indicator data as DataFrame
88
- handle = client.indicators.get(600) # PVPC price
88
+ handle = client.indicators.get(600) # Day-ahead spot price (OMIE)
89
89
  df = handle.historical("2025-01-01", "2025-01-31")
90
90
 
91
91
  # Search indicators
@@ -99,8 +99,8 @@ client.archives.download(1, start="2025-01-01", end="2025-01-31", output_dir="./
99
99
 
100
100
  | ID | Name | Description |
101
101
  |----|------|-------------|
102
- | 600 | PVPC | Voluntary price for small consumers |
103
- | 1001 | Day-ahead price | OMIE spot market price |
102
+ | 600 | Day-ahead price | OMIE spot market price |
103
+ | 1001 | PVPC | Voluntary price for small consumers (2.0TD) |
104
104
  | 10033 | Demand | Real-time electricity demand |
105
105
  | 10034 | Wind generation | Real-time wind generation |
106
106
  | 10035 | Solar PV generation | Real-time solar generation |
@@ -50,7 +50,7 @@ from esios import ESIOSClient
50
50
  client = ESIOSClient()
51
51
 
52
52
  # Get indicator data as DataFrame
53
- handle = client.indicators.get(600) # PVPC price
53
+ handle = client.indicators.get(600) # Day-ahead spot price (OMIE)
54
54
  df = handle.historical("2025-01-01", "2025-01-31")
55
55
 
56
56
  # Search indicators
@@ -64,8 +64,8 @@ client.archives.download(1, start="2025-01-01", end="2025-01-31", output_dir="./
64
64
 
65
65
  | ID | Name | Description |
66
66
  |----|------|-------------|
67
- | 600 | PVPC | Voluntary price for small consumers |
68
- | 1001 | Day-ahead price | OMIE spot market price |
67
+ | 600 | Day-ahead price | OMIE spot market price |
68
+ | 1001 | PVPC | Voluntary price for small consumers (2.0TD) |
69
69
  | 10033 | Demand | Real-time electricity demand |
70
70
  | 10034 | Wind generation | Real-time wind generation |
71
71
  | 10035 | Solar PV generation | Real-time solar generation |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-esios"
7
- version = "2.3.0"
7
+ version = "2.4.1"
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
+ ]
@@ -60,8 +60,8 @@ print(sheet.frequency) # "hourly" or "hourly-quarterly"
60
60
 
61
61
  | ID | Name | Description | Geos |
62
62
  |----|------|-------------|------|
63
- | 600 | Precio mercado spot | OMIE spot market price | ES, PT, FR, DE, BE, NL |
64
- | 1001 | Precio mercado diario | Day-ahead market price | ES |
63
+ | 600 | Precio mercado SPOT Diario | OMIE spot / day-ahead market price | ES, PT, FR, DE, BE, NL |
64
+ | 1001 | PVPC T. 2.0TD | Término de facturación de energía activa del PVPC (voluntary price for small consumers) | Península, Canarias, Baleares, Ceuta, Melilla |
65
65
  | 10033 | Demanda real | Real-time electricity demand | ES |
66
66
  | 10034 | Generación eólica | Real-time wind generation | ES |
67
67
  | 10035 | Generación solar FV | Real-time solar PV generation | ES |
@@ -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,
@@ -143,11 +232,15 @@ class IndicatorHandle:
143
232
  time_trunc: str | None = None,
144
233
  geo_trunc: str | None = None,
145
234
  column_name: str | None = None,
235
+ chunk_workers: int = DEFAULT_CHUNK_WORKERS,
146
236
  ) -> pd.DataFrame:
147
237
  """Fetch historical values as a DataFrame with DatetimeIndex.
148
238
 
149
239
  Uses local parquet cache when enabled. Only fetches missing date ranges
150
- 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.
151
244
 
152
245
  When multiple geo_ids are present (e.g. indicator 600 returns data for
153
246
  several countries), the result is pivoted so each geo becomes a column
@@ -158,6 +251,8 @@ class IndicatorHandle:
158
251
  Useful for single-column results where a stable name like
159
252
  ``"value"`` is preferred over the default geo_name or
160
253
  indicator ID.
254
+ chunk_workers: Number of concurrent threads for fetching chunks.
255
+ Defaults to 4. Set to 1 for sequential fetching.
161
256
  """
162
257
  base_params: dict[str, Any] = {
163
258
  "locale": locale,
@@ -211,24 +306,8 @@ class IndicatorHandle:
211
306
  from esios.cache import DateRange
212
307
  gaps = [DateRange(start_date, end_date)]
213
308
 
214
- # -- Fetch missing ranges ----------------------------------------------
215
- all_values: list[dict] = []
216
- chunk_delta = timedelta(days=CHUNK_SIZE_DAYS)
217
-
218
- for gap in gaps:
219
- current = gap.start
220
- gap_end = gap.end
221
- while current <= gap_end:
222
- chunk_end = min(current + chunk_delta, gap_end)
223
- params = {
224
- **base_params,
225
- "start_date": current.strftime("%Y-%m-%d"),
226
- "end_date": chunk_end.strftime("%Y-%m-%d") + "T23:59:59",
227
- }
228
- logger.debug("Fetch %s → %s", params["start_date"], params["end_date"])
229
- data = self._manager._get(f"indicators/{self.id}", params=params)
230
- all_values.extend(data.get("indicator", {}).get("values", []))
231
- 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)
232
311
 
233
312
  # Learn any new geo mappings from the response
234
313
  self._enrich_geo_map(all_values)
@@ -33,6 +33,43 @@ def _any_value_greater_than_30(series: np.ndarray) -> bool:
33
33
  return any(v > 30 for v in series if isinstance(v, (int, float, np.integer, np.floating)) and not np.isnan(v))
34
34
 
35
35
 
36
+ # Labels that REE uses for the cells sitting between the index columns and the
37
+ # per-period value columns of an I90 sheet. Match is exact (case-insensitive,
38
+ # trimmed). The count of these cells per sheet has varied across REE format
39
+ # revisions — historically 2 ("Hora" / "Cuarto de Hora del dia" + "Total");
40
+ # from Oct 2025 the MTU 15-min transition dropped "Total" on several sheets,
41
+ # leaving 1. Counting them dynamically keeps the parser resilient to either.
42
+ _SEPARATOR_LABELS = frozenset({
43
+ "cuarto de hora del dia",
44
+ "hora del dia",
45
+ "hora",
46
+ "total",
47
+ "indicadores",
48
+ })
49
+
50
+
51
+ def _count_header_separators(row: np.ndarray, idx_col_start: int) -> int:
52
+ """Count separator cells immediately preceding the time-value block.
53
+
54
+ Walks ``row`` backwards from ``idx_col_start - 1``, incrementing on each
55
+ cell whose text matches a known separator label and stopping at the first
56
+ non-matching cell or NaN. A NaN cell signals the start of the index-column
57
+ placeholder zone in double-header layouts (where index labels live on the
58
+ other header row, leaving the date row blank under each index position).
59
+ """
60
+ n = 0
61
+ for i in range(idx_col_start - 1, -1, -1):
62
+ cell = row[i]
63
+ if cell is None or (isinstance(cell, float) and np.isnan(cell)):
64
+ break
65
+ text = str(cell).strip().lower()
66
+ if text in _SEPARATOR_LABELS:
67
+ n += 1
68
+ continue
69
+ break
70
+ return n
71
+
72
+
36
73
  class I90Book:
37
74
  """Represents an I90DIA workbook (XLS) with lazy sheet preprocessing.
38
75
 
@@ -221,6 +258,11 @@ class I90Sheet:
221
258
  return pd.DataFrame()
222
259
 
223
260
  columns_date = self._normalize_datetime_columns(columns_prior[idx_col_start:])
261
+ # _normalize_datetime_columns sets _n_columns_totals from the time-block
262
+ # content (NaN-filler vs sequential). Override with a header-label count
263
+ # so the index slice survives REE format revisions that add or drop a
264
+ # "Total" column without touching the time-axis encoding.
265
+ self._n_columns_totals = _count_header_separators(columns_prior, idx_col_start)
224
266
  columns_variable = columns[idx_col_start:]
225
267
  columns_index = columns[: idx_col_start - self._n_columns_totals]
226
268
 
@@ -230,6 +272,7 @@ class I90Sheet:
230
272
  self, idx_col_start: int, columns: np.ndarray
231
273
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray, None]:
232
274
  columns_date = self._normalize_datetime_columns(columns[idx_col_start:])
275
+ self._n_columns_totals = _count_header_separators(columns, idx_col_start)
233
276
  columns_index = columns[: idx_col_start - self._n_columns_totals]
234
277
  return columns, columns_index, columns_date, None
235
278
 
@@ -26,12 +26,12 @@ def client(mock_httpx):
26
26
 
27
27
  @pytest.fixture
28
28
  def sample_indicator_response():
29
- """Sample API response for GET /indicators/600."""
29
+ """Sample API response for GET /indicators/1001."""
30
30
  return {
31
31
  "indicator": {
32
- "id": 600,
33
- "name": "PVPC T. Defecto",
34
- "short_name": "PVPC",
32
+ "id": 1001,
33
+ "name": "Término de facturación de energía activa del PVPC 2.0TD",
34
+ "short_name": "PVPC T. 2.0TD",
35
35
  "description": "<p>Precio voluntario para el pequeño consumidor</p>",
36
36
  "values": [
37
37
  {
@@ -39,16 +39,16 @@ def sample_indicator_response():
39
39
  "datetime": "2025-01-01T00:00:00.000+01:00",
40
40
  "datetime_utc": "2024-12-31T23:00:00Z",
41
41
  "tz_time": "2025-01-01T00:00:00.000+01:00",
42
- "geo_id": 3,
43
- "geo_name": "España",
42
+ "geo_id": 8741,
43
+ "geo_name": "Península",
44
44
  },
45
45
  {
46
46
  "value": 115.3,
47
47
  "datetime": "2025-01-01T01:00:00.000+01:00",
48
48
  "datetime_utc": "2025-01-01T00:00:00Z",
49
49
  "tz_time": "2025-01-01T01:00:00.000+01:00",
50
- "geo_id": 3,
51
- "geo_name": "España",
50
+ "geo_id": 8741,
51
+ "geo_name": "Península",
52
52
  },
53
53
  ],
54
54
  }
@@ -61,10 +61,10 @@ def sample_indicators_list_response():
61
61
  return {
62
62
  "indicators": [
63
63
  {
64
- "id": 600,
65
- "name": "PVPC T. Defecto",
66
- "short_name": "PVPC",
67
- "description": "<p>Precio voluntario</p>",
64
+ "id": 1001,
65
+ "name": "Término de facturación de energía activa del PVPC 2.0TD",
66
+ "short_name": "PVPC T. 2.0TD",
67
+ "description": "<p>Precio voluntario para el pequeño consumidor</p>",
68
68
  },
69
69
  {
70
70
  "id": 10034,
@@ -0,0 +1,319 @@
1
+ """Tests for I90 file processing — frequency detection and column normalisation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from esios.processing.i90 import (
11
+ I90Sheet,
12
+ _any_value_greater_than_30,
13
+ _count_header_separators,
14
+ )
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def _make_sheet() -> I90Sheet:
23
+ """Return a bare I90Sheet instance backed by mocked objects."""
24
+ wb = MagicMock()
25
+ sheet = MagicMock()
26
+ sheet.to_python.return_value = [[""]]
27
+ wb.get_sheet_by_name.return_value = sheet
28
+ return I90Sheet("test", wb, "I90DIA_20241001.xls", {})
29
+
30
+
31
+ def _full_nan_filler_columns(n_hours: int = 24) -> np.ndarray:
32
+ """Build NaN-filler quarterly columns: [1, NaN, NaN, NaN, 2, NaN, …]."""
33
+ cols: list = []
34
+ for h in range(1, n_hours + 1):
35
+ cols.append(h)
36
+ cols.extend([np.nan, np.nan, np.nan])
37
+ return np.array(cols, dtype=object)
38
+
39
+
40
+ def _full_hq_columns(n_hours: int = 24) -> np.ndarray:
41
+ """Build explicit H-Q columns: ['1-1', '1-2', '1-3', '1-4', '2-1', …]."""
42
+ return np.array(
43
+ [f"{h}-{q}" for h in range(1, n_hours + 1) for q in range(1, 5)],
44
+ dtype=object,
45
+ )
46
+
47
+
48
+ def _mixed_hq_columns(n_hours: int = 24) -> np.ndarray:
49
+ """First quarter is unlabelled ('1', '2', …); rest carry '-Q' suffix."""
50
+ cols: list = []
51
+ for h in range(1, n_hours + 1):
52
+ cols.append(str(h))
53
+ for q in range(2, 5):
54
+ cols.append(f"{h}-{q}")
55
+ return np.array(cols, dtype=object)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # _any_value_greater_than_30
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ class TestAnyValueGreaterThan30:
64
+ def test_returns_true_for_value_above_30(self):
65
+ assert _any_value_greater_than_30(np.array([1, 31, 24])) is True
66
+
67
+ def test_returns_false_for_all_values_24_or_less(self):
68
+ assert _any_value_greater_than_30(np.arange(1, 25)) is False
69
+
70
+ def test_works_with_numpy_int64(self):
71
+ """numpy 2.x broke isinstance(np.int64, int) — make sure we handle it."""
72
+ arr = np.array([1, 2, 31, 96], dtype=np.int64)
73
+ assert _any_value_greater_than_30(arr) is True
74
+
75
+ def test_ignores_nan(self):
76
+ arr = np.array([np.nan, 5.0, 20.0])
77
+ assert _any_value_greater_than_30(arr) is False
78
+
79
+ def test_sequential_quarterly_1_to_96(self):
80
+ assert _any_value_greater_than_30(np.arange(1, 97)) is True
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # _normalize_datetime_columns — hourly (unchanged behaviour)
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ class TestNormalizeHourly:
89
+ def test_sequential_1_to_24(self):
90
+ s = _make_sheet()
91
+ cols = np.array([float(i) for i in range(1, 25)], dtype=object)
92
+ result = s._normalize_datetime_columns(cols)
93
+ assert list(result) == list(range(1, 25))
94
+ assert not _any_value_greater_than_30(result)
95
+
96
+ def test_n_columns_totals_is_2_when_no_nan(self):
97
+ s = _make_sheet()
98
+ cols = np.array([float(i) for i in range(1, 25)], dtype=object)
99
+ s._normalize_datetime_columns(cols)
100
+ assert s._n_columns_totals == 2
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # _normalize_datetime_columns — quarterly (new behaviour)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class TestNormalizeQuarterlySequential:
109
+ """Columns already in 1–96 sequential form (already worked before the fix)."""
110
+
111
+ def test_sequential_1_to_96(self):
112
+ s = _make_sheet()
113
+ cols = np.array([float(i) for i in range(1, 97)], dtype=object)
114
+ result = s._normalize_datetime_columns(cols)
115
+ assert list(result) == list(range(1, 97))
116
+ assert _any_value_greater_than_30(result)
117
+
118
+
119
+ class TestNormalizeQuarterlyHQFormat:
120
+ """Columns in explicit 'H-Q' dash notation: '1-1', '1-2', …, '24-4'."""
121
+
122
+ def test_full_day_96_periods(self):
123
+ s = _make_sheet()
124
+ result = s._normalize_datetime_columns(_full_hq_columns())
125
+ assert len(result) == 96
126
+ assert list(result) == list(range(1, 97))
127
+ assert _any_value_greater_than_30(result)
128
+
129
+ def test_time_deltas_are_correct(self):
130
+ """period 1 → 0 min (00:00), period 96 → 1425 min (23:45)."""
131
+ s = _make_sheet()
132
+ result = s._normalize_datetime_columns(_full_hq_columns())
133
+ time_deltas = (result - 1) * 15
134
+ assert time_deltas[0] == 0
135
+ assert time_deltas[-1] == 1425
136
+
137
+ def test_mixed_hq_first_quarter_unlabelled(self):
138
+ """'1', '1-2', '1-3', '1-4', '2', '2-2', … is treated the same."""
139
+ s = _make_sheet()
140
+ result = s._normalize_datetime_columns(_mixed_hq_columns())
141
+ assert len(result) == 96
142
+ assert list(result) == list(range(1, 97))
143
+ assert _any_value_greater_than_30(result)
144
+
145
+ def test_n_columns_totals_is_2_for_explicit_hq(self):
146
+ s = _make_sheet()
147
+ s._normalize_datetime_columns(_full_hq_columns())
148
+ assert s._n_columns_totals == 2
149
+
150
+
151
+ class TestNormalizeQuarterlyNaNFiller:
152
+ """Columns in NaN-filler form: [1, NaN, NaN, NaN, 2, NaN, …]."""
153
+
154
+ def test_full_day_96_periods(self):
155
+ s = _make_sheet()
156
+ result = s._normalize_datetime_columns(_full_nan_filler_columns())
157
+ assert len(result) == 96
158
+ assert list(result) == list(range(1, 97))
159
+ assert _any_value_greater_than_30(result)
160
+
161
+ def test_time_deltas_are_correct(self):
162
+ s = _make_sheet()
163
+ result = s._normalize_datetime_columns(_full_nan_filler_columns())
164
+ time_deltas = (result - 1) * 15
165
+ assert time_deltas[0] == 0
166
+ assert time_deltas[-1] == 1425
167
+
168
+ def test_n_columns_totals_is_3_when_nan_present(self):
169
+ s = _make_sheet()
170
+ s._normalize_datetime_columns(_full_nan_filler_columns())
171
+ assert s._n_columns_totals == 3
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # _count_header_separators — header-label-based separator counting
176
+ #
177
+ # Headers below are calques of real REE I90 layouts. The counter walks back
178
+ # from idx_col_start, counting cells whose text equals a known separator
179
+ # label ('Cuarto de Hora del dia', 'Hora', 'Total', 'Indicadores', 'Hora del
180
+ # dia'), stopping at the first non-matching cell or NaN.
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ class TestCountHeaderSeparators:
185
+ def test_returns_zero_when_no_separators(self):
186
+ """If the cell right before the time block is a real index column."""
187
+ row = np.array(["Redespacho", "Tipo", "1.0", "2.0"], dtype=object)
188
+ assert _count_header_separators(row, idx_col_start=2) == 0
189
+
190
+ def test_pre_mtu_rr_price_layout_returns_2(self):
191
+ """I90DIA11 jun-2025: [Redespacho, Tipo, Cuarto de Hora del dia, Total, 1, …]."""
192
+ row = np.array(
193
+ ["Redespacho", "Tipo", "Cuarto de Hora del dia", "Total", "1.0", "2.0"],
194
+ dtype=object,
195
+ )
196
+ assert _count_header_separators(row, idx_col_start=4) == 2
197
+
198
+ def test_post_mtu_rr_price_layout_returns_1(self):
199
+ """I90DIA11 nov-2025: 'Total' dropped → [Redespacho, Cuarto de Hora del dia, 1, …]."""
200
+ row = np.array(
201
+ ["Redespacho", "Cuarto de Hora del dia", "1.0", "2.0"],
202
+ dtype=object,
203
+ )
204
+ assert _count_header_separators(row, idx_col_start=2) == 1
205
+
206
+ def test_pre_2024_hourly_layout_returns_2(self):
207
+ """I90DIA08 2014: [Redespacho, Tipo, …, Signo de Energía, Hora, Total, 00-01, …]."""
208
+ row = np.array(
209
+ [
210
+ "Redespacho", "Tipo", "Sentido", "Unidad de Programación",
211
+ "Tipo Oferta", "Tipo cálculo", "Tipo Restricción",
212
+ "Signo de Energía", "Hora", "Total", "00-01", "01-02",
213
+ ],
214
+ dtype=object,
215
+ )
216
+ assert _count_header_separators(row, idx_col_start=10) == 2
217
+
218
+ def test_post_mtu_rtr_price_layout_returns_1(self):
219
+ """I90DIA10 nov-2025: 'Total' dropped + index reordered."""
220
+ row = np.array(
221
+ [
222
+ "Redespacho", "Sentido", "Unidad de Programación", "Tipo Oferta",
223
+ "Tipo cálculo", "Signo de Energía", "Cuarto de Hora del dia",
224
+ "1.0", "2.0",
225
+ ],
226
+ dtype=object,
227
+ )
228
+ assert _count_header_separators(row, idx_col_start=7) == 1
229
+
230
+ def test_double_index_date_row_with_nan_index_placeholders(self):
231
+ """Double-header layout (e.g. I90DIA30 jun-2025): the date row carries
232
+ the separator labels; positions under each real index column are NaN.
233
+ The counter must stop at the first NaN (= start of the index zone).
234
+ """
235
+ row = np.array(
236
+ [np.nan, np.nan, np.nan, np.nan,
237
+ "Cuarto de Hora del dia", "Total", "1.0", "2.0"],
238
+ dtype=object,
239
+ )
240
+ assert _count_header_separators(row, idx_col_start=6) == 2
241
+
242
+ def test_double_index_post_mtu_dropped_total(self):
243
+ """I90DIA30 nov-2025 (single-index path post-MTU)."""
244
+ row = np.array(
245
+ ["Redespacho", "Sentido", "Tipo QH",
246
+ "Cuarto de Hora del dia", "1.0", "2.0"],
247
+ dtype=object,
248
+ )
249
+ assert _count_header_separators(row, idx_col_start=4) == 1
250
+
251
+ def test_match_is_case_insensitive(self):
252
+ row = np.array(["Redespacho", "TOTAL", "1.0"], dtype=object)
253
+ assert _count_header_separators(row, idx_col_start=2) == 1
254
+
255
+ def test_match_is_whitespace_tolerant(self):
256
+ row = np.array(["Redespacho", " Total ", "1.0"], dtype=object)
257
+ assert _count_header_separators(row, idx_col_start=2) == 1
258
+
259
+ def test_indicadores_counts_as_separator(self):
260
+ """Variable-row layout: [Redespacho, Tipo, Indicadores, Precio Marginal …, …]."""
261
+ row = np.array(
262
+ ["Redespacho", "Tipo", "Indicadores", "Precio Marginal", "1.0"],
263
+ dtype=object,
264
+ )
265
+ # idx_col_start = 4 → walk back from i=3
266
+ # i=3 'Precio Marginal' is not a separator → stop, return 0
267
+ assert _count_header_separators(row, idx_col_start=4) == 0
268
+ # But when adjacent to time block it counts:
269
+ row2 = np.array(["Redespacho", "Tipo", "Indicadores", "1.0"], dtype=object)
270
+ assert _count_header_separators(row2, idx_col_start=3) == 1
271
+
272
+ def test_unknown_label_immediately_after_index_stops_counter(self):
273
+ """Non-separator labels do not get absorbed even when next to time block."""
274
+ row = np.array(["Redespacho", "Foo Bar", "1.0"], dtype=object)
275
+ assert _count_header_separators(row, idx_col_start=2) == 0
276
+
277
+ def test_empty_string_breaks_counter(self):
278
+ row = np.array(["Redespacho", "", "Total", "1.0"], dtype=object)
279
+ # i=2 'Total' → sep, i=1 '' → break → returns 1
280
+ assert _count_header_separators(row, idx_col_start=3) == 1
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # _preprocess_*_index — verify the dynamic counter overrides the heuristic
285
+ # from _normalize_datetime_columns when slicing the index portion.
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ class TestPreprocessOverrideIntegration:
290
+ def test_single_index_post_mtu_layout_keeps_redespacho_in_index(self):
291
+ """I90DIA11 nov-2025 regression: pre-fix this consumed 'Redespacho' as
292
+ a 'Total' placeholder and returned an empty DataFrame. Post-fix the
293
+ index slice keeps it.
294
+ """
295
+ s = _make_sheet()
296
+ # Plant a quarterly time block to match real shape (96 periods).
297
+ time_block = np.array([float(i) for i in range(1, 97)], dtype=object)
298
+ columns = np.concatenate([
299
+ np.array(["Redespacho", "Cuarto de Hora del dia"], dtype=object),
300
+ time_block,
301
+ ])
302
+ result = s._preprocess_single_index(idx_col_start=2, columns=columns)
303
+ _, columns_index, _, _ = result
304
+ assert list(columns_index) == ["Redespacho"]
305
+ assert s._n_columns_totals == 1
306
+
307
+ def test_single_index_pre_mtu_layout_still_drops_total(self):
308
+ """I90DIA11 jun-2025 (and 2014-2024): two separators (Cuarto/Hora + Total)."""
309
+ s = _make_sheet()
310
+ time_block = np.array([float(i) for i in range(1, 97)], dtype=object)
311
+ columns = np.concatenate([
312
+ np.array(["Redespacho", "Tipo", "Cuarto de Hora del dia", "Total"],
313
+ dtype=object),
314
+ time_block,
315
+ ])
316
+ result = s._preprocess_single_index(idx_col_start=4, columns=columns)
317
+ _, columns_index, _, _ = result
318
+ assert list(columns_index) == ["Redespacho", "Tipo"]
319
+ assert s._n_columns_totals == 2
@@ -20,7 +20,7 @@ class TestIndicatorsManager:
20
20
  df = client.indicators.list()
21
21
  assert isinstance(df, pd.DataFrame)
22
22
  assert len(df) == 3
23
- assert "PVPC T. Defecto" in df["name"].values
23
+ assert "Término de facturación de energía activa del PVPC 2.0TD" in df["name"].values
24
24
 
25
25
  def test_search(self, client, mock_httpx, sample_indicators_list_response):
26
26
  response = MagicMock()
@@ -38,9 +38,9 @@ class TestIndicatorsManager:
38
38
  response.json.return_value = sample_indicator_response
39
39
  mock_httpx.get.return_value = response
40
40
 
41
- handle = client.indicators.get(600)
42
- assert handle.id == 600
43
- assert handle.name == "PVPC T. Defecto"
41
+ handle = client.indicators.get(1001)
42
+ assert handle.id == 1001
43
+ assert handle.name == "Término de facturación de energía activa del PVPC 2.0TD"
44
44
 
45
45
  def test_historical_returns_dataframe(
46
46
  self, client, mock_httpx, sample_indicator_response
@@ -50,7 +50,7 @@ class TestIndicatorsManager:
50
50
  response.json.return_value = sample_indicator_response
51
51
  mock_httpx.get.return_value = response
52
52
 
53
- handle = client.indicators.get(600)
53
+ handle = client.indicators.get(1001)
54
54
  df = handle.historical("2025-01-01", "2025-01-01")
55
55
  assert isinstance(df, pd.DataFrame)
56
56
  assert len(df) == 2
@@ -134,15 +134,15 @@ class TestIndicatorsCaching:
134
134
  mock_httpx.get.return_value = response
135
135
 
136
136
  # First call: hits API
137
- handle1 = cached_client.indicators.get(600)
137
+ handle1 = cached_client.indicators.get(1001)
138
138
  assert mock_httpx.get.call_count == 1
139
- assert handle1.id == 600
139
+ assert handle1.id == 1001
140
140
 
141
141
  # Second call: should use cached meta (no additional API call)
142
- handle2 = cached_client.indicators.get(600)
142
+ handle2 = cached_client.indicators.get(1001)
143
143
  assert mock_httpx.get.call_count == 1 # No new API call
144
- assert handle2.id == 600
145
- assert handle2.name == "PVPC T. Defecto"
144
+ assert handle2.id == 1001
145
+ assert handle2.name == "Término de facturación de energía activa del PVPC 2.0TD"
146
146
 
147
147
  def test_get_persists_geos_to_registry(
148
148
  self, cached_client, mock_httpx,
@@ -8,13 +8,13 @@ from esios.models.offer_indicator import OfferIndicator
8
8
  class TestIndicator:
9
9
  def test_from_api(self):
10
10
  data = {
11
- "id": 600,
11
+ "id": 1001,
12
12
  "name": "PVPC",
13
13
  "short_name": "PVPC",
14
14
  "description": "Precio voluntario",
15
15
  }
16
16
  ind = Indicator.from_api(data)
17
- assert ind.id == 600
17
+ assert ind.id == 1001
18
18
  assert ind.name == "PVPC"
19
19
  assert ind.raw == data
20
20
 
@@ -1,3 +0,0 @@
1
- {
2
- ".": "2.3.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"
@@ -1,167 +0,0 @@
1
- """Tests for I90 file processing — frequency detection and column normalisation."""
2
-
3
- from __future__ import annotations
4
-
5
- from unittest.mock import MagicMock
6
-
7
- import numpy as np
8
- import pytest
9
-
10
- from esios.processing.i90 import I90Sheet, _any_value_greater_than_30
11
-
12
-
13
- # ---------------------------------------------------------------------------
14
- # Helpers
15
- # ---------------------------------------------------------------------------
16
-
17
-
18
- def _make_sheet() -> I90Sheet:
19
- """Return a bare I90Sheet instance backed by mocked objects."""
20
- wb = MagicMock()
21
- sheet = MagicMock()
22
- sheet.to_python.return_value = [[""]]
23
- wb.get_sheet_by_name.return_value = sheet
24
- return I90Sheet("test", wb, "I90DIA_20241001.xls", {})
25
-
26
-
27
- def _full_nan_filler_columns(n_hours: int = 24) -> np.ndarray:
28
- """Build NaN-filler quarterly columns: [1, NaN, NaN, NaN, 2, NaN, …]."""
29
- cols: list = []
30
- for h in range(1, n_hours + 1):
31
- cols.append(h)
32
- cols.extend([np.nan, np.nan, np.nan])
33
- return np.array(cols, dtype=object)
34
-
35
-
36
- def _full_hq_columns(n_hours: int = 24) -> np.ndarray:
37
- """Build explicit H-Q columns: ['1-1', '1-2', '1-3', '1-4', '2-1', …]."""
38
- return np.array(
39
- [f"{h}-{q}" for h in range(1, n_hours + 1) for q in range(1, 5)],
40
- dtype=object,
41
- )
42
-
43
-
44
- def _mixed_hq_columns(n_hours: int = 24) -> np.ndarray:
45
- """First quarter is unlabelled ('1', '2', …); rest carry '-Q' suffix."""
46
- cols: list = []
47
- for h in range(1, n_hours + 1):
48
- cols.append(str(h))
49
- for q in range(2, 5):
50
- cols.append(f"{h}-{q}")
51
- return np.array(cols, dtype=object)
52
-
53
-
54
- # ---------------------------------------------------------------------------
55
- # _any_value_greater_than_30
56
- # ---------------------------------------------------------------------------
57
-
58
-
59
- class TestAnyValueGreaterThan30:
60
- def test_returns_true_for_value_above_30(self):
61
- assert _any_value_greater_than_30(np.array([1, 31, 24])) is True
62
-
63
- def test_returns_false_for_all_values_24_or_less(self):
64
- assert _any_value_greater_than_30(np.arange(1, 25)) is False
65
-
66
- def test_works_with_numpy_int64(self):
67
- """numpy 2.x broke isinstance(np.int64, int) — make sure we handle it."""
68
- arr = np.array([1, 2, 31, 96], dtype=np.int64)
69
- assert _any_value_greater_than_30(arr) is True
70
-
71
- def test_ignores_nan(self):
72
- arr = np.array([np.nan, 5.0, 20.0])
73
- assert _any_value_greater_than_30(arr) is False
74
-
75
- def test_sequential_quarterly_1_to_96(self):
76
- assert _any_value_greater_than_30(np.arange(1, 97)) is True
77
-
78
-
79
- # ---------------------------------------------------------------------------
80
- # _normalize_datetime_columns — hourly (unchanged behaviour)
81
- # ---------------------------------------------------------------------------
82
-
83
-
84
- class TestNormalizeHourly:
85
- def test_sequential_1_to_24(self):
86
- s = _make_sheet()
87
- cols = np.array([float(i) for i in range(1, 25)], dtype=object)
88
- result = s._normalize_datetime_columns(cols)
89
- assert list(result) == list(range(1, 25))
90
- assert not _any_value_greater_than_30(result)
91
-
92
- def test_n_columns_totals_is_2_when_no_nan(self):
93
- s = _make_sheet()
94
- cols = np.array([float(i) for i in range(1, 25)], dtype=object)
95
- s._normalize_datetime_columns(cols)
96
- assert s._n_columns_totals == 2
97
-
98
-
99
- # ---------------------------------------------------------------------------
100
- # _normalize_datetime_columns — quarterly (new behaviour)
101
- # ---------------------------------------------------------------------------
102
-
103
-
104
- class TestNormalizeQuarterlySequential:
105
- """Columns already in 1–96 sequential form (already worked before the fix)."""
106
-
107
- def test_sequential_1_to_96(self):
108
- s = _make_sheet()
109
- cols = np.array([float(i) for i in range(1, 97)], dtype=object)
110
- result = s._normalize_datetime_columns(cols)
111
- assert list(result) == list(range(1, 97))
112
- assert _any_value_greater_than_30(result)
113
-
114
-
115
- class TestNormalizeQuarterlyHQFormat:
116
- """Columns in explicit 'H-Q' dash notation: '1-1', '1-2', …, '24-4'."""
117
-
118
- def test_full_day_96_periods(self):
119
- s = _make_sheet()
120
- result = s._normalize_datetime_columns(_full_hq_columns())
121
- assert len(result) == 96
122
- assert list(result) == list(range(1, 97))
123
- assert _any_value_greater_than_30(result)
124
-
125
- def test_time_deltas_are_correct(self):
126
- """period 1 → 0 min (00:00), period 96 → 1425 min (23:45)."""
127
- s = _make_sheet()
128
- result = s._normalize_datetime_columns(_full_hq_columns())
129
- time_deltas = (result - 1) * 15
130
- assert time_deltas[0] == 0
131
- assert time_deltas[-1] == 1425
132
-
133
- def test_mixed_hq_first_quarter_unlabelled(self):
134
- """'1', '1-2', '1-3', '1-4', '2', '2-2', … is treated the same."""
135
- s = _make_sheet()
136
- result = s._normalize_datetime_columns(_mixed_hq_columns())
137
- assert len(result) == 96
138
- assert list(result) == list(range(1, 97))
139
- assert _any_value_greater_than_30(result)
140
-
141
- def test_n_columns_totals_is_2_for_explicit_hq(self):
142
- s = _make_sheet()
143
- s._normalize_datetime_columns(_full_hq_columns())
144
- assert s._n_columns_totals == 2
145
-
146
-
147
- class TestNormalizeQuarterlyNaNFiller:
148
- """Columns in NaN-filler form: [1, NaN, NaN, NaN, 2, NaN, …]."""
149
-
150
- def test_full_day_96_periods(self):
151
- s = _make_sheet()
152
- result = s._normalize_datetime_columns(_full_nan_filler_columns())
153
- assert len(result) == 96
154
- assert list(result) == list(range(1, 97))
155
- assert _any_value_greater_than_30(result)
156
-
157
- def test_time_deltas_are_correct(self):
158
- s = _make_sheet()
159
- result = s._normalize_datetime_columns(_full_nan_filler_columns())
160
- time_deltas = (result - 1) * 15
161
- assert time_deltas[0] == 0
162
- assert time_deltas[-1] == 1425
163
-
164
- def test_n_columns_totals_is_3_when_nan_present(self):
165
- s = _make_sheet()
166
- s._normalize_datetime_columns(_full_nan_filler_columns())
167
- assert s._n_columns_totals == 3
File without changes
File without changes
File without changes