python-esios 2.0.1__tar.gz → 2.1.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 (80) hide show
  1. python_esios-2.0.1/.github/workflows/publish.yml → python_esios-2.1.0/.github/workflows/release-please.yml +18 -4
  2. {python_esios-2.0.1 → python_esios-2.1.0}/.gitignore +6 -0
  3. python_esios-2.1.0/.release-please-manifest.json +3 -0
  4. {python_esios-2.0.1 → python_esios-2.1.0}/CHANGELOG.md +21 -0
  5. python_esios-2.1.0/CLAUDE.md +21 -0
  6. {python_esios-2.0.1 → python_esios-2.1.0}/PKG-INFO +8 -2
  7. {python_esios-2.0.1 → python_esios-2.1.0}/pyproject.toml +8 -2
  8. python_esios-2.1.0/release-please-config.json +19 -0
  9. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/.agents/skills/esios/SKILL.md +49 -4
  10. python_esios-2.1.0/src/esios/cli/archives.py +217 -0
  11. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/managers/indicators.py +5 -0
  12. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/processing/i90.py +25 -4
  13. python_esios-2.1.0/tests/test_i90.py +167 -0
  14. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_managers.py +56 -2
  15. python_esios-2.0.1/src/esios/cli/archives.py +0 -66
  16. {python_esios-2.0.1 → python_esios-2.1.0}/LICENSE +0 -0
  17. {python_esios-2.0.1 → python_esios-2.1.0}/README.md +0 -0
  18. {python_esios-2.0.1 → python_esios-2.1.0}/examples/.gitignore +0 -0
  19. {python_esios-2.0.1 → python_esios-2.1.0}/examples/01_Quickstart/01_Setup and First Query.ipynb +0 -0
  20. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/01_Search Indicators.ipynb +0 -0
  21. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/02_Historical Data.ipynb +0 -0
  22. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/03_Multi-Geography Indicators.ipynb +0 -0
  23. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/04_Compare Indicators.ipynb +0 -0
  24. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/05_Market Prices.ipynb +0 -0
  25. {python_esios-2.0.1 → python_esios-2.1.0}/examples/02_Indicators/06_Generation and Demand.ipynb +0 -0
  26. {python_esios-2.0.1 → python_esios-2.1.0}/examples/03_Archives/01_Download Archives.ipynb +0 -0
  27. {python_esios-2.0.1 → python_esios-2.1.0}/examples/03_Archives/02_I90 Settlement Files.ipynb +0 -0
  28. {python_esios-2.0.1 → python_esios-2.1.0}/examples/04_Caching/01_Cache Management.ipynb +0 -0
  29. {python_esios-2.0.1 → python_esios-2.1.0}/examples/05_Advanced/01_Ad-hoc Pandas Expressions.ipynb +0 -0
  30. {python_esios-2.0.1 → python_esios-2.1.0}/examples/05_Advanced/02_Async Client.ipynb +0 -0
  31. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/01_Quickstart/01_Setup and First Query.yaml +0 -0
  32. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/01_Search Indicators.yaml +0 -0
  33. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/02_Historical Data.yaml +0 -0
  34. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/03_Multi-Geography Indicators.yaml +0 -0
  35. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/04_Compare Indicators.yaml +0 -0
  36. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/05_Market Prices.yaml +0 -0
  37. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/02_Indicators/06_Generation and Demand.yaml +0 -0
  38. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/03_Archives/01_Download Archives.yaml +0 -0
  39. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/03_Archives/02_I90 Settlement Files.yaml +0 -0
  40. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/04_Caching/01_Cache Management.yaml +0 -0
  41. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/05_Advanced/01_Ad-hoc Pandas Expressions.yaml +0 -0
  42. {python_esios-2.0.1 → python_esios-2.1.0}/examples/_specs/05_Advanced/02_Async Client.yaml +0 -0
  43. {python_esios-2.0.1 → python_esios-2.1.0}/examples/generate.py +0 -0
  44. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/__init__.py +0 -0
  45. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/async_client.py +0 -0
  46. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cache.py +0 -0
  47. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/__init__.py +0 -0
  48. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/app.py +0 -0
  49. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/cache_cmd.py +0 -0
  50. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/config.py +0 -0
  51. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/config_cmd.py +0 -0
  52. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/exec_cmd.py +0 -0
  53. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/cli/indicators.py +0 -0
  54. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/client.py +0 -0
  55. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/constants.py +0 -0
  56. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/data/__init__.py +0 -0
  57. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/data/catalogs/__init__.py +0 -0
  58. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/data/catalogs/archives/__init__.py +0 -0
  59. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/data/catalogs/archives/catalog.py +0 -0
  60. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/data/catalogs/archives/refresh.py +0 -0
  61. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/exceptions.py +0 -0
  62. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/managers/__init__.py +0 -0
  63. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/managers/archives.py +0 -0
  64. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/managers/base.py +0 -0
  65. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/managers/offer_indicators.py +0 -0
  66. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/models/__init__.py +0 -0
  67. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/models/archive.py +0 -0
  68. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/models/indicator.py +0 -0
  69. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/models/offer_indicator.py +0 -0
  70. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/processing/__init__.py +0 -0
  71. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/processing/dataframes.py +0 -0
  72. {python_esios-2.0.1 → python_esios-2.1.0}/src/esios/processing/zip.py +0 -0
  73. {python_esios-2.0.1 → python_esios-2.1.0}/tests/__init__.py +0 -0
  74. {python_esios-2.0.1 → python_esios-2.1.0}/tests/conftest.py +0 -0
  75. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_cache.py +0 -0
  76. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_client.py +0 -0
  77. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_dataframes.py +0 -0
  78. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_exceptions.py +0 -0
  79. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_models.py +0 -0
  80. {python_esios-2.0.1 → python_esios-2.1.0}/tests/test_zip.py +0 -0
@@ -1,14 +1,30 @@
1
- name: Publish to PyPI
1
+ name: Release Please
2
2
 
3
3
  on:
4
4
  push:
5
5
  branches: [main]
6
6
 
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+
7
11
  jobs:
12
+ release-please:
13
+ runs-on: ubuntu-latest
14
+ outputs:
15
+ release_created: ${{ steps.release.outputs.release_created }}
16
+ steps:
17
+ - uses: googleapis/release-please-action@v4
18
+ id: release
19
+ with:
20
+ token: ${{ secrets.GITHUB_TOKEN }}
21
+
8
22
  publish:
23
+ needs: release-please
24
+ if: needs.release-please.outputs.release_created == 'true'
9
25
  runs-on: ubuntu-latest
10
26
  permissions:
11
- id-token: write # Required for trusted publishing
27
+ id-token: write
12
28
  steps:
13
29
  - uses: actions/checkout@v4
14
30
 
@@ -24,5 +40,3 @@ jobs:
24
40
 
25
41
  - name: Publish to PyPI
26
42
  uses: pypa/gh-action-pypi-publish@release/v1
27
- with:
28
- skip-existing: true
@@ -24,6 +24,9 @@ Thumbs.db
24
24
  *.swp
25
25
  *.swo
26
26
 
27
+ # Claude Code
28
+ .claude/
29
+
27
30
  # Generated docs
28
31
  docs/
29
32
 
@@ -46,3 +49,6 @@ htmlcov/
46
49
 
47
50
  # Dev scripts
48
51
  random/
52
+
53
+ # Backups
54
+ examples_backup/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.1.0"
3
+ }
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0](https://github.com/datons/python-esios/compare/python-esios-v2.0.2...python-esios-v2.1.0) (2026-03-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * add archives sheets and exec CLI commands for I90 processing ([b54f94a](https://github.com/datons/python-esios/commit/b54f94adfa56ee2150fc85f92055b002b0d3e9bb))
9
+ * add static archives catalog (153 archives) and improve download API ([22e6095](https://github.com/datons/python-esios/commit/22e60953fb3d5314452c03807be3e12595b88fd4))
10
+ * replace manual PyPI workflow with release-please ([3c0dcb3](https://github.com/datons/python-esios/commit/3c0dcb35b01a47ea489d5a1ecb891d6805486356))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * correct author email to datons.com ([8e17132](https://github.com/datons/python-esios/commit/8e1713248a56928ba61898b5b84b0b4689798ce3))
16
+ * rename release-please config to expected non-dotted filename ([2f53b76](https://github.com/datons/python-esios/commit/2f53b7658c3f3e02e53dcbb3a2fe149b8f16ad7c))
17
+ * update archives test to match static catalog default ([9bbd98b](https://github.com/datons/python-esios/commit/9bbd98b56dee2ae1cfbed24537ad989f5214ae76))
18
+
19
+
20
+ ### Performance
21
+
22
+ * lazy sheet loading in I90Book — only read sheets on demand ([2dfe941](https://github.com/datons/python-esios/commit/2dfe941bf71c74bb0bd07c72c90e92b61a0d3059))
23
+
3
24
  ## [2.0.0] - 2026-02-27
4
25
 
5
26
  Complete rewrite of the library with a new architecture, CLI, and caching system.
@@ -0,0 +1,21 @@
1
+ # python-esios
2
+
3
+ ## Releasing
4
+
5
+ This project uses [release-please](https://github.com/googleapis/release-please) for automated versioning and publishing.
6
+
7
+ **How it works:**
8
+
9
+ 1. Write conventional commits (`feat:`, `fix:`, `chore:`, etc.)
10
+ 2. On push to `main`, release-please auto-opens/updates a **Release PR** with version bump + CHANGELOG
11
+ 3. Merge the Release PR → GitHub Release + tag created → PyPI publish via trusted publishing
12
+
13
+ **Key files:**
14
+
15
+ - `.release-please-config.json` — release-please configuration (package type, changelog sections)
16
+ - `.release-please-manifest.json` — tracks current version
17
+ - `.github/workflows/release-please.yml` — workflow (release PR management + PyPI publish)
18
+
19
+ **Version is tracked in** `pyproject.toml` (`project.version`). Release-please bumps it automatically based on commit types (`feat:` → minor, `fix:` → patch).
20
+
21
+ **Never bump the version manually** — let release-please handle it via the Release PR.
@@ -1,12 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-esios
3
- Version: 2.0.1
3
+ Version: 2.1.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
7
- Author-email: Jesús López <jesus.lopez@datons.ai>
7
+ Project-URL: Issues, https://github.com/datons/python-esios/issues
8
+ Project-URL: Changelog, https://github.com/datons/python-esios/releases
9
+ Author-email: Jesús López <jesus.lopez@datons.com>
8
10
  License-Expression: GPL-3.0-only
9
11
  License-File: LICENSE
12
+ Keywords: api,electricity,energy,esios,ree,spain
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Science/Research
10
15
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
11
16
  Classifier: Operating System :: OS Independent
12
17
  Classifier: Programming Language :: Python :: 3
@@ -14,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.10
14
19
  Classifier: Programming Language :: Python :: 3.11
15
20
  Classifier: Programming Language :: Python :: 3.12
16
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering
17
23
  Requires-Python: >=3.10
18
24
  Requires-Dist: beautifulsoup4>=4.12
19
25
  Requires-Dist: httpx>=0.27
@@ -4,15 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-esios"
7
- version = "2.0.1"
7
+ version = "2.1.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"
11
11
  requires-python = ">=3.10"
12
12
  authors = [
13
- { name = "Jesús López", email = "jesus.lopez@datons.ai" },
13
+ { name = "Jesús López", email = "jesus.lopez@datons.com" },
14
14
  ]
15
+ keywords = ["esios", "ree", "energy", "electricity", "spain", "api"]
15
16
  classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Science/Research",
16
19
  "Programming Language :: Python :: 3",
17
20
  "Programming Language :: Python :: 3.10",
18
21
  "Programming Language :: Python :: 3.11",
@@ -20,6 +23,7 @@ classifiers = [
20
23
  "Programming Language :: Python :: 3.13",
21
24
  "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
22
25
  "Operating System :: OS Independent",
26
+ "Topic :: Scientific/Engineering",
23
27
  ]
24
28
  dependencies = [
25
29
  "httpx>=0.27",
@@ -39,6 +43,8 @@ esios = "esios.cli:app"
39
43
  [project.urls]
40
44
  Homepage = "https://github.com/datons/python-esios"
41
45
  Repository = "https://github.com/datons/python-esios"
46
+ Issues = "https://github.com/datons/python-esios/issues"
47
+ Changelog = "https://github.com/datons/python-esios/releases"
42
48
 
43
49
  [tool.hatch.build.targets.wheel]
44
50
  packages = ["src/esios"]
@@ -0,0 +1,19 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "release-type": "python",
5
+ "package-name": "python-esios",
6
+ "extra-files": [
7
+ "pyproject.toml"
8
+ ],
9
+ "changelog-sections": [
10
+ { "type": "feat", "section": "Features" },
11
+ { "type": "fix", "section": "Bug Fixes" },
12
+ { "type": "perf", "section": "Performance" },
13
+ { "type": "docs", "section": "Documentation", "hidden": true },
14
+ { "type": "chore", "section": "Miscellaneous", "hidden": true }
15
+ ]
16
+ }
17
+ },
18
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
19
+ }
@@ -39,8 +39,22 @@ esios indicators exec 600 -s 2025-01-01 -e 2025-01-31 --expr "df.resample('D').m
39
39
  ### Archives
40
40
 
41
41
  ```bash
42
+ # List all available archives
42
43
  esios archives list
43
- esios archives download 1 --start 2025-01-01 --end 2025-01-31 --output ./data
44
+
45
+ # Download archive files
46
+ esios archives download 34 --start 2025-05-01 --end 2025-05-31 --output ./data
47
+ esios archives download 34 --date 2025-06-01
48
+
49
+ # List sheets (table of contents) in an I90 file
50
+ esios archives sheets 34 --date 2025-06-01
51
+
52
+ # Parse and query archive data (like indicators exec but for archives)
53
+ esios archives exec 34 --sheet I90DIA03 --date 2025-06-01
54
+ esios archives exec 34 --sheet I90DIA03 --date 2025-06-01 -x "df.describe()"
55
+ esios archives exec 34 --sheet I90DIA03 -s 2025-05-05 -e 2025-06-08 \
56
+ -x "df[df['Sentido']=='Bajar'].groupby('Unidad de Programación')['value'].sum().sort_values()"
57
+ esios archives exec 34 --sheet I90DIA26 --date 2025-06-01 --format csv --output pbf.csv
44
58
  ```
45
59
 
46
60
  ### Cache Management
@@ -132,11 +146,42 @@ df = client.indicators.compare([600, 10034, 10035], "2025-01-01", "2025-01-07")
132
146
 
133
147
  ## I90 Settlement Files
134
148
 
149
+ ### CLI (quickest path)
150
+
151
+ ```bash
152
+ # Discover available sheets
153
+ esios archives sheets 34 --date 2025-06-01
154
+
155
+ # Key I90DIA sheets:
156
+ # I90DIA03 — Restricciones en el Mercado Diario (curtailment)
157
+ # I90DIA08 — Restricciones en Tiempo Real
158
+ # I90DIA26 — Programa Base de Funcionamiento (PBF, generation program)
159
+ # I90DIA01 — Programa PVP
160
+ # I90DIA07 — Regulación Terciaria (mFRR)
161
+
162
+ # Total curtailment by direction
163
+ esios archives exec 34 --sheet I90DIA03 --date 2025-06-01 \
164
+ -x "df.groupby('Sentido')['value'].sum()"
165
+
166
+ # Multi-day curtailment analysis
167
+ esios archives exec 34 --sheet I90DIA03 -s 2025-05-05 -e 2025-06-08 \
168
+ -x "df[df['Sentido']=='Bajar'].groupby('Unidad de Programación')['value'].sum().sort_values().head(20)"
169
+ ```
170
+
171
+ ### Python library
172
+
135
173
  ```python
136
- from esios.processing import I90Book
174
+ from esios import ESIOSClient
175
+ from esios.processing.i90 import I90Book
176
+
177
+ # Download + parse in one step
178
+ client = ESIOSClient()
179
+ archive = client.archives.get(34)
180
+ books = I90Book.from_archive(archive, start="2025-05-05", end="2025-06-08")
137
181
 
138
- book = I90Book("path/to/I90DIA_20250101.xls")
139
- sheet = book["3.1"] # Access specific sheet
182
+ # Access a sheet
183
+ book = books[0]
184
+ sheet = book["I90DIA03"]
140
185
  df = sheet.df # Preprocessed DataFrame with datetime index
141
186
  print(sheet.frequency) # "hourly" or "hourly-quarterly"
142
187
  ```
@@ -0,0 +1,217 @@
1
+ """CLI subcommands for archives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ archives_app = typer.Typer(no_args_is_help=True)
12
+ console = Console()
13
+
14
+
15
+ @archives_app.command("list")
16
+ def list_archives(
17
+ token: Optional[str] = typer.Option(None, "--token", "-t"),
18
+ format: str = typer.Option("table", "--format", "-f"),
19
+ ):
20
+ """List all available ESIOS archives."""
21
+ from esios.cli.app import get_client
22
+
23
+ client = get_client(token)
24
+ df = client.archives.list()
25
+
26
+ if format == "csv":
27
+ typer.echo(df.to_csv())
28
+ elif format == "json":
29
+ typer.echo(df.to_json(orient="records", indent=2))
30
+ else:
31
+ table = Table(title="ESIOS Archives")
32
+ cols = list(df.columns)[:8]
33
+ for col in cols:
34
+ table.add_column(str(col))
35
+ for _, row in df.head(50).iterrows():
36
+ table.add_row(*[str(row[c]) for c in cols])
37
+ if len(df) > 50:
38
+ table.caption = f"Showing 50 of {len(df)} rows"
39
+ console.print(table)
40
+
41
+
42
+ @archives_app.command("download")
43
+ def download_archive(
44
+ archive_id: int = typer.Argument(..., help="Archive ID"),
45
+ start: Optional[str] = typer.Option(None, "--start", "-s", help="Start date (YYYY-MM-DD)"),
46
+ end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (YYYY-MM-DD)"),
47
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Single date (YYYY-MM-DD)"),
48
+ output: str = typer.Option(".", "--output", "-o", help="Output directory"),
49
+ token: Optional[str] = typer.Option(None, "--token", "-t"),
50
+ ):
51
+ """Download an archive for a date or date range."""
52
+ from esios.cli.app import get_client
53
+
54
+ if not date and not (start and end):
55
+ typer.echo("Provide --date or both --start and --end.", err=True)
56
+ raise typer.Exit(1)
57
+
58
+ client = get_client(token)
59
+ client.archives.download(
60
+ archive_id,
61
+ start=start,
62
+ end=end,
63
+ date=date,
64
+ output_dir=output,
65
+ )
66
+ typer.echo(f"Download complete → {output}")
67
+
68
+
69
+ def _download_files(
70
+ archive_id: int,
71
+ token: str | None,
72
+ date: str | None,
73
+ start: str | None,
74
+ end: str | None,
75
+ ) -> list:
76
+ """Download archive files (shared by sheets and exec commands)."""
77
+ from esios.cli.app import get_client
78
+
79
+ if not date and not (start and end):
80
+ typer.echo("Provide --date or both --start and --end.", err=True)
81
+ raise typer.Exit(1)
82
+
83
+ client = get_client(token)
84
+
85
+ if date:
86
+ files = client.archives.download(archive_id, date=date)
87
+ else:
88
+ files = client.archives.download(archive_id, start=start, end=end)
89
+
90
+ if not files:
91
+ typer.echo("No files found for the given date range.", err=True)
92
+ raise typer.Exit(1)
93
+
94
+ return files
95
+
96
+
97
+ @archives_app.command("sheets")
98
+ def sheets(
99
+ archive_id: int = typer.Argument(..., help="Archive ID (e.g. 34 for I90DIA)"),
100
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Single date (YYYY-MM-DD)"),
101
+ start: Optional[str] = typer.Option(None, "--start", "-s", help="Start date (YYYY-MM-DD)"),
102
+ end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (YYYY-MM-DD)"),
103
+ token: Optional[str] = typer.Option(None, "--token", "-t"),
104
+ ):
105
+ """List sheets (table of contents) in an archive file.
106
+
107
+ Downloads and parses the first file to show available sheets.
108
+
109
+ \b
110
+ Examples:
111
+ esios archives sheets 34 --date 2025-06-01
112
+ esios archives sheets 34 --start 2025-06-01 --end 2025-06-01
113
+ """
114
+ from esios.processing.i90 import I90Book
115
+
116
+ files = _download_files(archive_id, token, date, start, end)
117
+
118
+ try:
119
+ book = I90Book(files[0])
120
+ except Exception as exc:
121
+ typer.echo(f"Error parsing {files[0].name}: {exc}", err=True)
122
+ raise typer.Exit(1)
123
+
124
+ table = Table(title=f"Sheets in {files[0].name}")
125
+ table.add_column("Sheet", style="cyan")
126
+ table.add_column("Description")
127
+
128
+ for sheet_name, description in book.table_of_contents.items():
129
+ if not isinstance(sheet_name, str) or not sheet_name.strip():
130
+ continue
131
+ table.add_row(str(sheet_name), str(description))
132
+
133
+ console.print(table)
134
+
135
+
136
+ @archives_app.command("exec")
137
+ def exec_archive(
138
+ archive_id: int = typer.Argument(..., help="Archive ID (e.g. 34 for I90DIA)"),
139
+ sheet: str = typer.Option(..., "--sheet", help="Sheet name (e.g. I90DIA03, I90DIA26)"),
140
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Single date (YYYY-MM-DD)"),
141
+ start: Optional[str] = typer.Option(None, "--start", "-s", help="Start date (YYYY-MM-DD)"),
142
+ end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (YYYY-MM-DD)"),
143
+ expr: str = typer.Option("df", "--expr", "-x", help="Python expression to evaluate (df, pd, np available)"),
144
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
145
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
146
+ token: Optional[str] = typer.Option(None, "--token", "-t"),
147
+ ):
148
+ """Parse archive files and evaluate a Python expression on the data.
149
+
150
+ Downloads archive files (cached), parses the specified sheet from each
151
+ file using I90Book, concatenates the DataFrames, and evaluates the
152
+ expression. The parsed data is available as `df` (pandas DataFrame).
153
+ `pd` (pandas) and `np` (numpy) are also available.
154
+
155
+ \b
156
+ Examples:
157
+ # Show curtailment data for a single day
158
+ esios archives exec 34 --sheet I90DIA03 --date 2025-06-01
159
+
160
+ # Curtailment by technology over a month
161
+ esios archives exec 34 --sheet I90DIA03 -s 2025-05-05 -e 2025-06-08 \\
162
+ -x "df[df['Sentido']=='Bajar'].groupby('Tecnología')['value'].sum().sort_values()"
163
+
164
+ # Export PBF generation program to CSV
165
+ esios archives exec 34 --sheet I90DIA26 --date 2025-06-01 -f csv -o pbf.csv
166
+
167
+ # Descriptive statistics
168
+ esios archives exec 34 --sheet I90DIA03 --date 2025-06-01 -x "df.describe()"
169
+ """
170
+ import numpy as np
171
+ import pandas as pd
172
+
173
+ from esios.processing.i90 import I90Book
174
+
175
+ files = _download_files(archive_id, token, date, start, end)
176
+
177
+ # Parse all files and extract the requested sheet
178
+ all_dfs = []
179
+ for f in files:
180
+ try:
181
+ book = I90Book(f)
182
+ s = book[sheet]
183
+ if s.df is not None and not s.df.empty:
184
+ all_dfs.append(s.df.reset_index())
185
+ except KeyError:
186
+ # Sheet not found — show available sheets
187
+ try:
188
+ book = I90Book(f)
189
+ available = list(book.table_of_contents.keys())
190
+ except Exception:
191
+ available = []
192
+ typer.echo(f"Sheet '{sheet}' not found in {f.name}.", err=True)
193
+ if available:
194
+ typer.echo(f"Available sheets: {', '.join(str(s) for s in available)}", err=True)
195
+ raise typer.Exit(1)
196
+ except Exception as exc:
197
+ typer.echo(f"Warning: skipping {f.name}: {exc}", err=True)
198
+ continue
199
+
200
+ if not all_dfs:
201
+ typer.echo("No data extracted from the specified sheet.", err=True)
202
+ raise typer.Exit(1)
203
+
204
+ df = pd.concat(all_dfs, ignore_index=True)
205
+
206
+ # Evaluate expression
207
+ namespace = {"df": df, "pd": pd, "np": np}
208
+ try:
209
+ result = eval(expr, {"__builtins__": {}}, namespace) # noqa: S307
210
+ except Exception as exc:
211
+ typer.echo(f"Error evaluating expression: {exc}", err=True)
212
+ raise typer.Exit(1)
213
+
214
+ # Render output — reuse _render from exec_cmd
215
+ from esios.cli.exec_cmd import _render
216
+
217
+ _render(result, format, output)
@@ -385,6 +385,11 @@ class IndicatorsManager(BaseManager):
385
385
  indicator = Indicator.from_api(raw)
386
386
  handle = IndicatorHandle(self, indicator)
387
387
 
388
+ # Enrich geos from values returned in metadata response.
389
+ # Some indicators (e.g. province-level breakdown) have an empty
390
+ # "geos" field in the metadata but geo_id/geo_name in the values.
391
+ handle._enrich_geo_map(raw.get("values", []))
392
+
388
393
  # Persist metadata and geos
389
394
  if cache.config.enabled:
390
395
  handle._persist_meta()
@@ -30,7 +30,7 @@ def _get_idx_column_start(columns: np.ndarray) -> int:
30
30
 
31
31
  def _any_value_greater_than_30(series: np.ndarray) -> bool:
32
32
  """Check if any numeric value exceeds 30 (quarter-hourly indicator)."""
33
- return any(v > 30 for v in series if isinstance(v, (int, float)) and not np.isnan(v))
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
36
  class I90Book:
@@ -164,15 +164,36 @@ class I90Sheet:
164
164
  return arr
165
165
 
166
166
  def _normalize_datetime_columns(self, columns: np.ndarray) -> np.ndarray:
167
- """Normalize time column headers to integer period indices."""
167
+ """Normalize time column headers to integer period indices.
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)
174
+ """
168
175
  if any(pd.isna(columns)):
169
176
  self._n_columns_totals = 3
170
177
  else:
171
178
  self._n_columns_totals = 2
172
179
 
173
180
  series = pd.Series(columns, dtype=str).ffill()
174
- series = series.str.split("-").str[0]
175
- return series.astype(float).astype(int).values
181
+ parts = series.str.split("-")
182
+ hours = parts.str[0].astype(float).astype(int)
183
+
184
+ # H-Q format: any column carries an explicit quarter suffix (e.g. "1-2")
185
+ if (parts.str.len() > 1).any():
186
+ quarters = parts.apply(
187
+ lambda x: int(x[1]) if len(x) > 1 and str(x[1]).isdigit() else 1
188
+ )
189
+ return ((hours - 1) * 4 + quarters).values
190
+
191
+ # NaN-filler quarterly format: after ffill the same hour number repeats
192
+ # four times (quarters share the hour label). Assign sequential indices.
193
+ if hours.duplicated().any():
194
+ return np.arange(1, len(hours) + 1)
195
+
196
+ return hours.values
176
197
 
177
198
  def _preprocess_double_index(
178
199
  self, idx: int, columns: np.ndarray
@@ -0,0 +1,167 @@
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
@@ -58,13 +58,20 @@ class TestIndicatorsManager:
58
58
 
59
59
 
60
60
  class TestArchivesManager:
61
- def test_list(self, client, mock_httpx, sample_archives_list_response):
61
+ def test_list_local(self, client):
62
+ """Default list() returns static catalog (153 archives)."""
63
+ df = client.archives.list()
64
+ assert isinstance(df, pd.DataFrame)
65
+ assert len(df) == 153
66
+
67
+ def test_list_api(self, client, mock_httpx, sample_archives_list_response):
68
+ """list(source='api') queries the ESIOS API."""
62
69
  response = MagicMock()
63
70
  response.status_code = 200
64
71
  response.json.return_value = sample_archives_list_response
65
72
  mock_httpx.get.return_value = response
66
73
 
67
- df = client.archives.list()
74
+ df = client.archives.list(source="api")
68
75
  assert isinstance(df, pd.DataFrame)
69
76
  assert len(df) == 2
70
77
 
@@ -165,6 +172,53 @@ class TestIndicatorsCaching:
165
172
  assert "1" in geos
166
173
  assert geos["1"] == "Portugal"
167
174
 
175
+ def test_get_enriches_geos_from_values_when_metadata_geos_empty(
176
+ self, cached_client, mock_httpx,
177
+ ):
178
+ """indicators.get() should populate geos from values when metadata geos is empty.
179
+
180
+ Reproduces the bug reported for indicators with province-level breakdown
181
+ (e.g. indicator 1161) where the API returns ``"geos": []`` in the
182
+ metadata field but each value carries ``geo_id`` / ``geo_name``.
183
+ """
184
+ indicator_response = {
185
+ "indicator": {
186
+ "id": 1161,
187
+ "name": "Generación medida Solar fotovoltaica",
188
+ "geos": [],
189
+ "values": [
190
+ {
191
+ "value": 100.0,
192
+ "datetime": "2025-01-01T00:00:00.000+01:00",
193
+ "datetime_utc": "2024-12-31T23:00:00Z",
194
+ "tz_time": "2025-01-01T00:00:00.000+01:00",
195
+ "geo_id": 4,
196
+ "geo_name": "Almería",
197
+ },
198
+ {
199
+ "value": 200.0,
200
+ "datetime": "2025-01-01T00:00:00.000+01:00",
201
+ "datetime_utc": "2024-12-31T23:00:00Z",
202
+ "tz_time": "2025-01-01T00:00:00.000+01:00",
203
+ "geo_id": 5,
204
+ "geo_name": "Cádiz",
205
+ },
206
+ ],
207
+ }
208
+ }
209
+ response = MagicMock()
210
+ response.status_code = 200
211
+ response.json.return_value = indicator_response
212
+ mock_httpx.get.return_value = response
213
+
214
+ handle = cached_client.indicators.get(1161)
215
+
216
+ # geos must be populated even though the metadata field was empty
217
+ assert len(handle.geos) == 2
218
+ geo_ids = {g["geo_id"] for g in handle.geos}
219
+ assert 4 in geo_ids
220
+ assert 5 in geo_ids
221
+
168
222
  def test_enrich_geo_map_persists(
169
223
  self, cached_client, mock_httpx,
170
224
  ):
@@ -1,66 +0,0 @@
1
- """CLI subcommands for archives."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Optional
6
-
7
- import typer
8
- from rich.console import Console
9
- from rich.table import Table
10
-
11
- archives_app = typer.Typer(no_args_is_help=True)
12
- console = Console()
13
-
14
-
15
- @archives_app.command("list")
16
- def list_archives(
17
- token: Optional[str] = typer.Option(None, "--token", "-t"),
18
- format: str = typer.Option("table", "--format", "-f"),
19
- ):
20
- """List all available ESIOS archives."""
21
- from esios.cli.app import get_client
22
-
23
- client = get_client(token)
24
- df = client.archives.list()
25
-
26
- if format == "csv":
27
- typer.echo(df.to_csv())
28
- elif format == "json":
29
- typer.echo(df.to_json(orient="records", indent=2))
30
- else:
31
- table = Table(title="ESIOS Archives")
32
- cols = list(df.columns)[:8]
33
- for col in cols:
34
- table.add_column(str(col))
35
- for _, row in df.head(50).iterrows():
36
- table.add_row(*[str(row[c]) for c in cols])
37
- if len(df) > 50:
38
- table.caption = f"Showing 50 of {len(df)} rows"
39
- console.print(table)
40
-
41
-
42
- @archives_app.command("download")
43
- def download_archive(
44
- archive_id: int = typer.Argument(..., help="Archive ID"),
45
- start: Optional[str] = typer.Option(None, "--start", "-s", help="Start date (YYYY-MM-DD)"),
46
- end: Optional[str] = typer.Option(None, "--end", "-e", help="End date (YYYY-MM-DD)"),
47
- date: Optional[str] = typer.Option(None, "--date", "-d", help="Single date (YYYY-MM-DD)"),
48
- output: str = typer.Option(".", "--output", "-o", help="Output directory"),
49
- token: Optional[str] = typer.Option(None, "--token", "-t"),
50
- ):
51
- """Download an archive for a date or date range."""
52
- from esios.cli.app import get_client
53
-
54
- if not date and not (start and end):
55
- typer.echo("Provide --date or both --start and --end.", err=True)
56
- raise typer.Exit(1)
57
-
58
- client = get_client(token)
59
- client.archives.download(
60
- archive_id,
61
- start=start,
62
- end=end,
63
- date=date,
64
- output_dir=output,
65
- )
66
- typer.echo(f"Download complete → {output}")
File without changes
File without changes