python-esios 2.4.0__tar.gz → 2.4.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 (84) hide show
  1. python_esios-2.4.2/.release-please-manifest.json +3 -0
  2. {python_esios-2.4.0 → python_esios-2.4.2}/CHANGELOG.md +15 -0
  3. {python_esios-2.4.0 → python_esios-2.4.2}/PKG-INFO +4 -4
  4. {python_esios-2.4.0 → python_esios-2.4.2}/README.md +3 -3
  5. {python_esios-2.4.0 → python_esios-2.4.2}/pyproject.toml +1 -1
  6. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/.agents/skills/esios/SKILL.md +2 -2
  7. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/processing/i90.py +67 -4
  8. {python_esios-2.4.0 → python_esios-2.4.2}/tests/conftest.py +12 -12
  9. python_esios-2.4.2/tests/test_i90.py +365 -0
  10. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_managers.py +10 -10
  11. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_models.py +2 -2
  12. python_esios-2.4.0/.release-please-manifest.json +0 -3
  13. python_esios-2.4.0/tests/test_i90.py +0 -167
  14. {python_esios-2.4.0 → python_esios-2.4.2}/.github/workflows/release-please.yml +0 -0
  15. {python_esios-2.4.0 → python_esios-2.4.2}/.gitignore +0 -0
  16. {python_esios-2.4.0 → python_esios-2.4.2}/CLAUDE.md +0 -0
  17. {python_esios-2.4.0 → python_esios-2.4.2}/LICENSE +0 -0
  18. {python_esios-2.4.0 → python_esios-2.4.2}/examples/.gitignore +0 -0
  19. {python_esios-2.4.0 → python_esios-2.4.2}/examples/01_Quickstart/01_Setup and First Query.ipynb +0 -0
  20. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/01_Search Indicators.ipynb +0 -0
  21. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/02_Historical Data.ipynb +0 -0
  22. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/03_Multi-Geography Indicators.ipynb +0 -0
  23. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/04_Compare Indicators.ipynb +0 -0
  24. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/05_Market Prices.ipynb +0 -0
  25. {python_esios-2.4.0 → python_esios-2.4.2}/examples/02_Indicators/06_Generation and Demand.ipynb +0 -0
  26. {python_esios-2.4.0 → python_esios-2.4.2}/examples/03_Archives/01_Download Archives.ipynb +0 -0
  27. {python_esios-2.4.0 → python_esios-2.4.2}/examples/03_Archives/02_I90 Settlement Files.ipynb +0 -0
  28. {python_esios-2.4.0 → python_esios-2.4.2}/examples/04_Caching/01_Cache Management.ipynb +0 -0
  29. {python_esios-2.4.0 → python_esios-2.4.2}/examples/05_Advanced/01_Ad-hoc Pandas Expressions.ipynb +0 -0
  30. {python_esios-2.4.0 → python_esios-2.4.2}/examples/05_Advanced/02_Async Client.ipynb +0 -0
  31. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/01_Quickstart/01_Setup and First Query.yaml +0 -0
  32. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/01_Search Indicators.yaml +0 -0
  33. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/02_Historical Data.yaml +0 -0
  34. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/03_Multi-Geography Indicators.yaml +0 -0
  35. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/04_Compare Indicators.yaml +0 -0
  36. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/05_Market Prices.yaml +0 -0
  37. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/02_Indicators/06_Generation and Demand.yaml +0 -0
  38. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/03_Archives/01_Download Archives.yaml +0 -0
  39. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/03_Archives/02_I90 Settlement Files.yaml +0 -0
  40. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/04_Caching/01_Cache Management.yaml +0 -0
  41. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/05_Advanced/01_Ad-hoc Pandas Expressions.yaml +0 -0
  42. {python_esios-2.4.0 → python_esios-2.4.2}/examples/_specs/05_Advanced/02_Async Client.yaml +0 -0
  43. {python_esios-2.4.0 → python_esios-2.4.2}/examples/generate.py +0 -0
  44. {python_esios-2.4.0 → python_esios-2.4.2}/release-please-config.json +0 -0
  45. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/__init__.py +0 -0
  46. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/async_client.py +0 -0
  47. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cache.py +0 -0
  48. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/catalog.py +0 -0
  49. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/__init__.py +0 -0
  50. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/app.py +0 -0
  51. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/archives.py +0 -0
  52. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/cache_cmd.py +0 -0
  53. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/catalog_cmd.py +0 -0
  54. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/config.py +0 -0
  55. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/config_cmd.py +0 -0
  56. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/exec_cmd.py +0 -0
  57. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/cli/indicators.py +0 -0
  58. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/client.py +0 -0
  59. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/constants.py +0 -0
  60. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/__init__.py +0 -0
  61. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/archives.yaml +0 -0
  62. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/geos.yaml +0 -0
  63. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/indicators.yaml +0 -0
  64. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/magnitudes.yaml +0 -0
  65. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/data/time_periods.yaml +0 -0
  66. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/exceptions.py +0 -0
  67. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/managers/__init__.py +0 -0
  68. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/managers/archives.py +0 -0
  69. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/managers/base.py +0 -0
  70. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/managers/indicators.py +0 -0
  71. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/managers/offer_indicators.py +0 -0
  72. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/models/__init__.py +0 -0
  73. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/models/archive.py +0 -0
  74. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/models/indicator.py +0 -0
  75. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/models/offer_indicator.py +0 -0
  76. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/processing/__init__.py +0 -0
  77. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/processing/dataframes.py +0 -0
  78. {python_esios-2.4.0 → python_esios-2.4.2}/src/esios/processing/zip.py +0 -0
  79. {python_esios-2.4.0 → python_esios-2.4.2}/tests/__init__.py +0 -0
  80. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_cache.py +0 -0
  81. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_client.py +0 -0
  82. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_dataframes.py +0 -0
  83. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_exceptions.py +0 -0
  84. {python_esios-2.4.0 → python_esios-2.4.2}/tests/test_zip.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.2"
3
+ }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.2](https://github.com/datons/python-esios/compare/python-esios-v2.4.1...python-esios-v2.4.2) (2026-06-11)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **i90:** pair MW and price per quarter-hour in multi-measure sheets ([06efdb6](https://github.com/datons/python-esios/commit/06efdb660077cc36c4435f0e36ae5f24e68e9189))
9
+
10
+ ## [2.4.1](https://github.com/datons/python-esios/compare/python-esios-v2.4.0...python-esios-v2.4.1) (2026-05-19)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **i90:** detect separator columns dynamically by header label ([1a9bf77](https://github.com/datons/python-esios/commit/1a9bf778eeb769aac836a945787c951c624f3806))
16
+ * **i90:** detect separator columns dynamically by header label ([d6c747f](https://github.com/datons/python-esios/commit/d6c747f4db44b5667abf12f075ef468eca3eec20))
17
+
3
18
  ## [2.4.0](https://github.com/datons/python-esios/compare/python-esios-v2.3.0...python-esios-v2.4.0) (2026-03-23)
4
19
 
5
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-esios
3
- Version: 2.4.0
3
+ Version: 2.4.2
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.4.0"
7
+ version = "2.4.2"
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"
@@ -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 |
@@ -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
 
@@ -163,18 +200,29 @@ class I90Sheet:
163
200
  arr[arr == ""] = np.nan
164
201
  return arr
165
202
 
166
- def _normalize_datetime_columns(self, columns: np.ndarray) -> np.ndarray:
203
+ def _normalize_datetime_columns(
204
+ self, columns: np.ndarray, n_measures: int = 1
205
+ ) -> np.ndarray:
167
206
  """Normalize time column headers to integer period indices.
168
207
 
169
- Handles four column formats found in I90 files:
208
+ Handles five column formats found in I90 files:
170
209
 
171
210
  1. Sequential integers: 1–24 (hourly) or 1–96 (quarterly)
172
211
  2. H-Q format: "1-1", "1-2", "1-3", "1-4", "2-1", …
173
- 3. NaN-filler format: [1, NaN, NaN, NaN, 2, …]
212
+ 3. NaN-filler format: [1, NaN, NaN, NaN, 2, …] — one period number per
213
+ hour, three NaN fillers for the remaining quarters (single measure).
174
214
  4. Range format (DST days): "00-01", "01-02", "02-03a", "02-03b", …
175
215
  where the first number is the start hour and a/b suffix marks
176
216
  the repeated hour on fall-back days. Detected by the first
177
217
  column starting with "0" (e.g. "00-01").
218
+ 5. Multi-measure format: [1, NaN, 2, NaN, …] — each period number labels
219
+ a block of `n_measures` sub-columns (e.g. MW + €/MWh paired per
220
+ quarter-hour in offer sheets), the NaN fillers being the extra
221
+ measures. The period number is on every n_measures-th column.
222
+
223
+ `n_measures` is the count of distinct measure sub-columns per period
224
+ (1 for single-measure sheets). It disambiguates format 3 from format 5,
225
+ which share the same NaN-filler shape but mean opposite things.
178
226
  """
179
227
  if any(pd.isna(columns)):
180
228
  self._n_columns_totals = 3
@@ -203,6 +251,10 @@ class I90Sheet:
203
251
  )
204
252
  return ((hours - 1) * 4 + quarters).values
205
253
 
254
+ # Multi-measure format: each period number labels a block of n_measures sub-columns (e.g. MW + €/MWh). The ffilled value already IS the period index and the duplication is the measures — keep it so every measure of a period shares one datetime (paired), instead of spreading them across sequential slots.
255
+ if n_measures > 1:
256
+ return hours.values
257
+
206
258
  # NaN-filler quarterly format: after ffill the same hour number repeats
207
259
  # four times (quarters share the hour label). Assign sequential indices.
208
260
  if hours.duplicated().any():
@@ -220,8 +272,18 @@ class I90Sheet:
220
272
  if idx_col_start == -1:
221
273
  return pd.DataFrame()
222
274
 
223
- columns_date = self._normalize_datetime_columns(columns_prior[idx_col_start:])
224
275
  columns_variable = columns[idx_col_start:]
276
+ # Distinct measure labels per period (e.g. {MW, €/MWh} -> 2). Disambiguates the NaN-filler time header (single measure, expand to sequential quarters) from the multi-measure paired layout (keep the period index so measures pair).
277
+ measure_labels = pd.Series(columns_variable).dropna()
278
+ n_measures = int(measure_labels.nunique()) if len(measure_labels) else 1
279
+ columns_date = self._normalize_datetime_columns(
280
+ columns_prior[idx_col_start:], n_measures=n_measures
281
+ )
282
+ # _normalize_datetime_columns sets _n_columns_totals from the time-block
283
+ # content (NaN-filler vs sequential). Override with a header-label count
284
+ # so the index slice survives REE format revisions that add or drop a
285
+ # "Total" column without touching the time-axis encoding.
286
+ self._n_columns_totals = _count_header_separators(columns_prior, idx_col_start)
225
287
  columns_index = columns[: idx_col_start - self._n_columns_totals]
226
288
 
227
289
  return columns, columns_index, columns_date, columns_variable
@@ -230,6 +292,7 @@ class I90Sheet:
230
292
  self, idx_col_start: int, columns: np.ndarray
231
293
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray, None]:
232
294
  columns_date = self._normalize_datetime_columns(columns[idx_col_start:])
295
+ self._n_columns_totals = _count_header_separators(columns, idx_col_start)
233
296
  columns_index = columns[: idx_col_start - self._n_columns_totals]
234
297
  return columns, columns_index, columns_date, None
235
298
 
@@ -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,365 @@
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
+ def _multi_measure_columns(n_periods: int = 96, n_measures: int = 2) -> np.ndarray:
59
+ """Multi-measure paired columns: the period number labels a block of
60
+ n_measures sub-columns (e.g. MW + €/MWh), the rest NaN: [1, NaN, 2, NaN, …].
61
+ """
62
+ cols: list = []
63
+ for p in range(1, n_periods + 1):
64
+ cols.append(p)
65
+ cols.extend([np.nan] * (n_measures - 1))
66
+ return np.array(cols, dtype=object)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # _any_value_greater_than_30
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ class TestAnyValueGreaterThan30:
75
+ def test_returns_true_for_value_above_30(self):
76
+ assert _any_value_greater_than_30(np.array([1, 31, 24])) is True
77
+
78
+ def test_returns_false_for_all_values_24_or_less(self):
79
+ assert _any_value_greater_than_30(np.arange(1, 25)) is False
80
+
81
+ def test_works_with_numpy_int64(self):
82
+ """numpy 2.x broke isinstance(np.int64, int) — make sure we handle it."""
83
+ arr = np.array([1, 2, 31, 96], dtype=np.int64)
84
+ assert _any_value_greater_than_30(arr) is True
85
+
86
+ def test_ignores_nan(self):
87
+ arr = np.array([np.nan, 5.0, 20.0])
88
+ assert _any_value_greater_than_30(arr) is False
89
+
90
+ def test_sequential_quarterly_1_to_96(self):
91
+ assert _any_value_greater_than_30(np.arange(1, 97)) is True
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # _normalize_datetime_columns — hourly (unchanged behaviour)
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class TestNormalizeHourly:
100
+ def test_sequential_1_to_24(self):
101
+ s = _make_sheet()
102
+ cols = np.array([float(i) for i in range(1, 25)], dtype=object)
103
+ result = s._normalize_datetime_columns(cols)
104
+ assert list(result) == list(range(1, 25))
105
+ assert not _any_value_greater_than_30(result)
106
+
107
+ def test_n_columns_totals_is_2_when_no_nan(self):
108
+ s = _make_sheet()
109
+ cols = np.array([float(i) for i in range(1, 25)], dtype=object)
110
+ s._normalize_datetime_columns(cols)
111
+ assert s._n_columns_totals == 2
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # _normalize_datetime_columns — quarterly (new behaviour)
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class TestNormalizeQuarterlySequential:
120
+ """Columns already in 1–96 sequential form (already worked before the fix)."""
121
+
122
+ def test_sequential_1_to_96(self):
123
+ s = _make_sheet()
124
+ cols = np.array([float(i) for i in range(1, 97)], dtype=object)
125
+ result = s._normalize_datetime_columns(cols)
126
+ assert list(result) == list(range(1, 97))
127
+ assert _any_value_greater_than_30(result)
128
+
129
+
130
+ class TestNormalizeQuarterlyHQFormat:
131
+ """Columns in explicit 'H-Q' dash notation: '1-1', '1-2', …, '24-4'."""
132
+
133
+ def test_full_day_96_periods(self):
134
+ s = _make_sheet()
135
+ result = s._normalize_datetime_columns(_full_hq_columns())
136
+ assert len(result) == 96
137
+ assert list(result) == list(range(1, 97))
138
+ assert _any_value_greater_than_30(result)
139
+
140
+ def test_time_deltas_are_correct(self):
141
+ """period 1 → 0 min (00:00), period 96 → 1425 min (23:45)."""
142
+ s = _make_sheet()
143
+ result = s._normalize_datetime_columns(_full_hq_columns())
144
+ time_deltas = (result - 1) * 15
145
+ assert time_deltas[0] == 0
146
+ assert time_deltas[-1] == 1425
147
+
148
+ def test_mixed_hq_first_quarter_unlabelled(self):
149
+ """'1', '1-2', '1-3', '1-4', '2', '2-2', … is treated the same."""
150
+ s = _make_sheet()
151
+ result = s._normalize_datetime_columns(_mixed_hq_columns())
152
+ assert len(result) == 96
153
+ assert list(result) == list(range(1, 97))
154
+ assert _any_value_greater_than_30(result)
155
+
156
+ def test_n_columns_totals_is_2_for_explicit_hq(self):
157
+ s = _make_sheet()
158
+ s._normalize_datetime_columns(_full_hq_columns())
159
+ assert s._n_columns_totals == 2
160
+
161
+
162
+ class TestNormalizeQuarterlyNaNFiller:
163
+ """Columns in NaN-filler form: [1, NaN, NaN, NaN, 2, NaN, …]."""
164
+
165
+ def test_full_day_96_periods(self):
166
+ s = _make_sheet()
167
+ result = s._normalize_datetime_columns(_full_nan_filler_columns())
168
+ assert len(result) == 96
169
+ assert list(result) == list(range(1, 97))
170
+ assert _any_value_greater_than_30(result)
171
+
172
+ def test_time_deltas_are_correct(self):
173
+ s = _make_sheet()
174
+ result = s._normalize_datetime_columns(_full_nan_filler_columns())
175
+ time_deltas = (result - 1) * 15
176
+ assert time_deltas[0] == 0
177
+ assert time_deltas[-1] == 1425
178
+
179
+ def test_n_columns_totals_is_3_when_nan_present(self):
180
+ s = _make_sheet()
181
+ s._normalize_datetime_columns(_full_nan_filler_columns())
182
+ assert s._n_columns_totals == 3
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # _count_header_separators — header-label-based separator counting
187
+ #
188
+ # Headers below are calques of real REE I90 layouts. The counter walks back
189
+ # from idx_col_start, counting cells whose text equals a known separator
190
+ # label ('Cuarto de Hora del dia', 'Hora', 'Total', 'Indicadores', 'Hora del
191
+ # dia'), stopping at the first non-matching cell or NaN.
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ class TestCountHeaderSeparators:
196
+ def test_returns_zero_when_no_separators(self):
197
+ """If the cell right before the time block is a real index column."""
198
+ row = np.array(["Redespacho", "Tipo", "1.0", "2.0"], dtype=object)
199
+ assert _count_header_separators(row, idx_col_start=2) == 0
200
+
201
+ def test_pre_mtu_rr_price_layout_returns_2(self):
202
+ """I90DIA11 jun-2025: [Redespacho, Tipo, Cuarto de Hora del dia, Total, 1, …]."""
203
+ row = np.array(
204
+ ["Redespacho", "Tipo", "Cuarto de Hora del dia", "Total", "1.0", "2.0"],
205
+ dtype=object,
206
+ )
207
+ assert _count_header_separators(row, idx_col_start=4) == 2
208
+
209
+ def test_post_mtu_rr_price_layout_returns_1(self):
210
+ """I90DIA11 nov-2025: 'Total' dropped → [Redespacho, Cuarto de Hora del dia, 1, …]."""
211
+ row = np.array(
212
+ ["Redespacho", "Cuarto de Hora del dia", "1.0", "2.0"],
213
+ dtype=object,
214
+ )
215
+ assert _count_header_separators(row, idx_col_start=2) == 1
216
+
217
+ def test_pre_2024_hourly_layout_returns_2(self):
218
+ """I90DIA08 2014: [Redespacho, Tipo, …, Signo de Energía, Hora, Total, 00-01, …]."""
219
+ row = np.array(
220
+ [
221
+ "Redespacho", "Tipo", "Sentido", "Unidad de Programación",
222
+ "Tipo Oferta", "Tipo cálculo", "Tipo Restricción",
223
+ "Signo de Energía", "Hora", "Total", "00-01", "01-02",
224
+ ],
225
+ dtype=object,
226
+ )
227
+ assert _count_header_separators(row, idx_col_start=10) == 2
228
+
229
+ def test_post_mtu_rtr_price_layout_returns_1(self):
230
+ """I90DIA10 nov-2025: 'Total' dropped + index reordered."""
231
+ row = np.array(
232
+ [
233
+ "Redespacho", "Sentido", "Unidad de Programación", "Tipo Oferta",
234
+ "Tipo cálculo", "Signo de Energía", "Cuarto de Hora del dia",
235
+ "1.0", "2.0",
236
+ ],
237
+ dtype=object,
238
+ )
239
+ assert _count_header_separators(row, idx_col_start=7) == 1
240
+
241
+ def test_double_index_date_row_with_nan_index_placeholders(self):
242
+ """Double-header layout (e.g. I90DIA30 jun-2025): the date row carries
243
+ the separator labels; positions under each real index column are NaN.
244
+ The counter must stop at the first NaN (= start of the index zone).
245
+ """
246
+ row = np.array(
247
+ [np.nan, np.nan, np.nan, np.nan,
248
+ "Cuarto de Hora del dia", "Total", "1.0", "2.0"],
249
+ dtype=object,
250
+ )
251
+ assert _count_header_separators(row, idx_col_start=6) == 2
252
+
253
+ def test_double_index_post_mtu_dropped_total(self):
254
+ """I90DIA30 nov-2025 (single-index path post-MTU)."""
255
+ row = np.array(
256
+ ["Redespacho", "Sentido", "Tipo QH",
257
+ "Cuarto de Hora del dia", "1.0", "2.0"],
258
+ dtype=object,
259
+ )
260
+ assert _count_header_separators(row, idx_col_start=4) == 1
261
+
262
+ def test_match_is_case_insensitive(self):
263
+ row = np.array(["Redespacho", "TOTAL", "1.0"], dtype=object)
264
+ assert _count_header_separators(row, idx_col_start=2) == 1
265
+
266
+ def test_match_is_whitespace_tolerant(self):
267
+ row = np.array(["Redespacho", " Total ", "1.0"], dtype=object)
268
+ assert _count_header_separators(row, idx_col_start=2) == 1
269
+
270
+ def test_indicadores_counts_as_separator(self):
271
+ """Variable-row layout: [Redespacho, Tipo, Indicadores, Precio Marginal …, …]."""
272
+ row = np.array(
273
+ ["Redespacho", "Tipo", "Indicadores", "Precio Marginal", "1.0"],
274
+ dtype=object,
275
+ )
276
+ # idx_col_start = 4 → walk back from i=3
277
+ # i=3 'Precio Marginal' is not a separator → stop, return 0
278
+ assert _count_header_separators(row, idx_col_start=4) == 0
279
+ # But when adjacent to time block it counts:
280
+ row2 = np.array(["Redespacho", "Tipo", "Indicadores", "1.0"], dtype=object)
281
+ assert _count_header_separators(row2, idx_col_start=3) == 1
282
+
283
+ def test_unknown_label_immediately_after_index_stops_counter(self):
284
+ """Non-separator labels do not get absorbed even when next to time block."""
285
+ row = np.array(["Redespacho", "Foo Bar", "1.0"], dtype=object)
286
+ assert _count_header_separators(row, idx_col_start=2) == 0
287
+
288
+ def test_empty_string_breaks_counter(self):
289
+ row = np.array(["Redespacho", "", "Total", "1.0"], dtype=object)
290
+ # i=2 'Total' → sep, i=1 '' → break → returns 1
291
+ assert _count_header_separators(row, idx_col_start=3) == 1
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # _preprocess_*_index — verify the dynamic counter overrides the heuristic
296
+ # from _normalize_datetime_columns when slicing the index portion.
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ class TestPreprocessOverrideIntegration:
301
+ def test_single_index_post_mtu_layout_keeps_redespacho_in_index(self):
302
+ """I90DIA11 nov-2025 regression: pre-fix this consumed 'Redespacho' as
303
+ a 'Total' placeholder and returned an empty DataFrame. Post-fix the
304
+ index slice keeps it.
305
+ """
306
+ s = _make_sheet()
307
+ # Plant a quarterly time block to match real shape (96 periods).
308
+ time_block = np.array([float(i) for i in range(1, 97)], dtype=object)
309
+ columns = np.concatenate([
310
+ np.array(["Redespacho", "Cuarto de Hora del dia"], dtype=object),
311
+ time_block,
312
+ ])
313
+ result = s._preprocess_single_index(idx_col_start=2, columns=columns)
314
+ _, columns_index, _, _ = result
315
+ assert list(columns_index) == ["Redespacho"]
316
+ assert s._n_columns_totals == 1
317
+
318
+ def test_single_index_pre_mtu_layout_still_drops_total(self):
319
+ """I90DIA11 jun-2025 (and 2014-2024): two separators (Cuarto/Hora + Total)."""
320
+ s = _make_sheet()
321
+ time_block = np.array([float(i) for i in range(1, 97)], dtype=object)
322
+ columns = np.concatenate([
323
+ np.array(["Redespacho", "Tipo", "Cuarto de Hora del dia", "Total"],
324
+ dtype=object),
325
+ time_block,
326
+ ])
327
+ result = s._preprocess_single_index(idx_col_start=4, columns=columns)
328
+ _, columns_index, _, _ = result
329
+ assert list(columns_index) == ["Redespacho", "Tipo"]
330
+ assert s._n_columns_totals == 2
331
+
332
+
333
+ class TestNormalizeMultiMeasure:
334
+ """Multi-measure paired columns ([1, NaN, 2, NaN, …]) share the same
335
+ NaN-filler shape as the single-measure format but mean the opposite: the
336
+ duplication is the measures (MW + €/MWh), so the period index must be kept
337
+ (paired), not expanded to sequential slots. Regression for offer sheets
338
+ (OFFER_BT / OFFER_BS_*) whose MW and price were otherwise scattered across
339
+ 8 slots/hour instead of paired into 4 quarter-hours.
340
+ """
341
+
342
+ def test_two_measures_keep_period_index(self):
343
+ s = _make_sheet()
344
+ result = s._normalize_datetime_columns(
345
+ _multi_measure_columns(4, n_measures=2), n_measures=2
346
+ )
347
+ # 4 quarters x 2 measures -> each quarter index repeated for its measures
348
+ assert list(result) == [1, 1, 2, 2, 3, 3, 4, 4]
349
+
350
+ def test_full_day_paired_96_periods(self):
351
+ s = _make_sheet()
352
+ result = s._normalize_datetime_columns(
353
+ _multi_measure_columns(96, n_measures=2), n_measures=2
354
+ )
355
+ assert len(result) == 192
356
+ assert result.max() == 96
357
+ _, counts = np.unique(result, return_counts=True)
358
+ assert set(counts.tolist()) == {2} # every period appears once per measure
359
+ assert _any_value_greater_than_30(result)
360
+
361
+ def test_single_measure_default_still_sequential(self):
362
+ # n_measures defaults to 1 -> old NaN-filler behaviour is preserved
363
+ s = _make_sheet()
364
+ result = s._normalize_datetime_columns(_full_nan_filler_columns())
365
+ assert list(result) == list(range(1, 97))
@@ -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.4.0"
3
- }
@@ -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