Skip to content

Commit 22e6095

Browse files
committed
feat: add static archives catalog (153 archives) and improve download API
The ESIOS /archives endpoint only returns ~24 archives, hiding 129 others (including I90DIA, settlements, PVPC, etc.) that are accessible by ID. - Add static catalog at src/esios/data/catalogs/archives/ with all 153 archives - Add refresh.py script to regenerate the catalog from the live API - archives.list() now returns the full catalog by default (source="local") - archives.download() now returns list[Path] instead of Path - I90Book.from_archive() classmethod for streamlined I90 workflow - Update SKILL.md with config file resolution note
1 parent 9aac3f6 commit 22e6095

8 files changed

Lines changed: 327 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ docs/
3131
*.xls
3232
*.xlsx
3333
data/
34+
!src/esios/data/
3435

3536
# Legacy code (superseded by src/)
3637
esios_legacy/

src/esios/data/__init__.py

Whitespace-only changes.

src/esios/data/catalogs/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Archives catalog — static reference of all known ESIOS archives."""
2+
3+
from esios.data.catalogs.archives.catalog import ARCHIVES_CATALOG
4+
5+
__all__ = ["ARCHIVES_CATALOG"]

src/esios/data/catalogs/archives/catalog.py

Lines changed: 163 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Refresh the static archives catalog by scanning the ESIOS API.
2+
3+
Usage:
4+
uv run python -m esios.data.catalogs.archives.refresh
5+
6+
Scans archive IDs 1-200 against the live API and regenerates catalog.py.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from pathlib import Path
13+
14+
15+
def scan_archives(max_id: int = 200) -> dict[int, dict[str, str]]:
16+
"""Scan ESIOS API for all accessible archives."""
17+
from esios import ESIOSClient
18+
19+
client = ESIOSClient()
20+
catalog: dict[int, dict[str, str]] = {}
21+
22+
for i in range(1, max_id + 1):
23+
try:
24+
data = client.get(f"archives/{i}")
25+
a = data.get("archive", {})
26+
catalog[i] = {
27+
"name": a.get("name", ""),
28+
"description": a.get("description", ""),
29+
"horizon": a.get("horizon", ""),
30+
"archive_type": a.get("archive_type", ""),
31+
}
32+
except Exception:
33+
continue
34+
35+
return catalog
36+
37+
38+
def generate_catalog_py(catalog: dict[int, dict[str, str]]) -> str:
39+
"""Generate the catalog.py source code."""
40+
lines = [
41+
'"""Static catalog of ESIOS archives.',
42+
"",
43+
"Auto-generated by refresh.py — do not edit manually.",
44+
'"""',
45+
"",
46+
"from __future__ import annotations",
47+
"",
48+
"ARCHIVES_CATALOG: dict[int, dict[str, str]] = {",
49+
]
50+
51+
for id_ in sorted(catalog):
52+
entry = catalog[id_]
53+
name = entry["name"]
54+
desc = entry["description"].replace('"', '\\"')
55+
horizon = entry["horizon"]
56+
atype = entry["archive_type"]
57+
lines.append(
58+
f' {id_}: {{"name": "{name}", "description": "{desc}", '
59+
f'"horizon": "{horizon}", "archive_type": "{atype}"}},'
60+
)
61+
62+
lines.append("}")
63+
lines.append("")
64+
return "\n".join(lines)
65+
66+
67+
def main() -> None:
68+
from esios.data.catalogs.archives.catalog import ARCHIVES_CATALOG
69+
70+
old_ids = set(ARCHIVES_CATALOG.keys())
71+
72+
print(f"Scanning ESIOS API for archives (IDs 1-200)...")
73+
catalog = scan_archives()
74+
new_ids = set(catalog.keys())
75+
76+
# Diff summary
77+
added = new_ids - old_ids
78+
removed = old_ids - new_ids
79+
80+
print(f"\nFound {len(catalog)} archives (was {len(old_ids)})")
81+
if added:
82+
print(f" Added: {sorted(added)}")
83+
if removed:
84+
print(f" Removed: {sorted(removed)}")
85+
if not added and not removed:
86+
print(" No changes in archive IDs.")
87+
88+
# Write catalog.py
89+
catalog_path = Path(__file__).parent / "catalog.py"
90+
catalog_path.write_text(generate_catalog_py(catalog))
91+
print(f"\nWrote {catalog_path}")
92+
93+
94+
if __name__ == "__main__":
95+
main()

src/esios/managers/archives.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,20 @@ def download(
7676
date: str | None = None,
7777
output_dir: str | Path | None = None,
7878
date_type: str = "datos",
79-
) -> Path:
79+
) -> list[Path]:
8080
"""Download archive files for a single date or date range.
8181
8282
Files are always stored in the cache directory. If ``output_dir`` is
8383
provided, a copy is placed there as well.
8484
85-
Returns the cache directory where files were stored.
85+
Returns a sorted list of downloaded/cached file paths.
8686
"""
8787
if date and not (start and end):
8888
self.configure(date=date, date_type=date_type)
8989
cache_folder = self._download_single()
9090
if output_dir:
9191
self._copy_to_output(cache_folder, Path(output_dir))
92-
return cache_folder
92+
return sorted(f for f in cache_folder.iterdir() if f.is_file())
9393

9494
if not (start and end):
9595
raise ValueError("Provide 'date' or both 'start' and 'end'.")
@@ -101,7 +101,7 @@ def download(
101101
horizon = self.metadata.get("archive", {}).get("horizon", "D")
102102
archive_type = self.metadata.get("archive", {}).get("archive_type", "zip")
103103
current = start_date
104-
last_folder: Path | None = None
104+
files: list[Path] = []
105105

106106
while current <= end_date:
107107
if horizon == "M":
@@ -118,7 +118,7 @@ def download(
118118
logger.info("Cache hit: %s", cache_folder)
119119
if output_dir:
120120
self._copy_to_output(cache_folder, Path(output_dir))
121-
last_folder = cache_folder
121+
files.extend(f for f in cache_folder.iterdir() if f.is_file())
122122
current = chunk_end + timedelta(days=1)
123123
continue
124124

@@ -136,15 +136,15 @@ def download(
136136
# Write to cache
137137
cache_folder = self._cache.archive_dir(self.id, self.name, key)
138138
self._write_content(content, cache_folder, key, archive_type)
139-
last_folder = cache_folder
139+
files.extend(f for f in cache_folder.iterdir() if f.is_file())
140140

141141
# Copy to output if requested
142142
if output_dir:
143143
self._copy_to_output(cache_folder, Path(output_dir))
144144

145145
current = chunk_end + timedelta(days=1)
146146

147-
return last_folder or self._cache.archive_dir(self.id, self.name, "")
147+
return sorted(files)
148148

149149
# -- Internal helpers ------------------------------------------------------
150150

@@ -199,12 +199,25 @@ def _copy_to_output(cache_folder: Path, output_dir: Path) -> None:
199199
class ArchivesManager(BaseManager):
200200
"""Manager for ``/archives`` endpoints."""
201201

202-
def list(self) -> pd.DataFrame:
203-
"""List all available archives as a DataFrame."""
204-
data = self._get("archives")
205-
df = pd.DataFrame(data.get("archives", []))
206-
if "id" in df.columns:
207-
df = df.set_index("id")
202+
def list(self, *, source: str = "local") -> pd.DataFrame:
203+
"""List all available archives as a DataFrame.
204+
205+
Args:
206+
source: ``"local"`` (default) returns the full static catalog
207+
(153 archives including I90, settlements, etc.).
208+
``"api"`` queries the ESIOS API which only returns ~24 archives.
209+
"""
210+
if source == "api":
211+
data = self._get("archives", params={"date_type": "publicacion"})
212+
df = pd.DataFrame(data.get("archives", []))
213+
if "id" in df.columns:
214+
df = df.set_index("id")
215+
return df
216+
217+
from esios.data.catalogs.archives import ARCHIVES_CATALOG
218+
219+
df = pd.DataFrame.from_dict(ARCHIVES_CATALOG, orient="index")
220+
df.index.name = "id"
208221
return df
209222

210223
def get(self, archive_id: int) -> ArchiveHandle:
@@ -225,10 +238,10 @@ def download(
225238
date: str | None = None,
226239
output_dir: str | Path | None = None,
227240
date_type: str = "datos",
228-
) -> Path:
241+
) -> list[Path]:
229242
"""Convenience method: get + download in one call.
230243
231-
Returns the cache directory where files were stored.
244+
Returns a sorted list of downloaded/cached file paths.
232245
"""
233246
handle = self.get(archive_id)
234247
return handle.download(

src/esios/processing/i90.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88

99
import logging
1010
from pathlib import Path
11+
from typing import TYPE_CHECKING
1112

1213
import numpy as np
1314
import pandas as pd
1415
import python_calamine
1516

17+
if TYPE_CHECKING:
18+
from esios.managers.archives import ArchiveHandle
19+
1620
logger = logging.getLogger("esios")
1721

1822

@@ -83,6 +87,36 @@ def get_sheet(self, sheet_name: str) -> I90Sheet:
8387
def __getitem__(self, sheet_name: str) -> I90Sheet:
8488
return self.get_sheet(sheet_name)
8589

90+
@classmethod
91+
def from_archive(
92+
cls,
93+
archive: ArchiveHandle,
94+
*,
95+
start: str,
96+
end: str,
97+
) -> list[I90Book]:
98+
"""Download I90 files and parse them into I90Book objects.
99+
100+
Calls ``archive.download()`` (cache-aware), then parses each file.
101+
Files that fail to parse are logged and skipped.
102+
103+
Args:
104+
archive: An :class:`ArchiveHandle` from ``client.archives.get(34)``.
105+
start: Start date (``"YYYY-MM-DD"``).
106+
end: End date (``"YYYY-MM-DD"``).
107+
108+
Returns:
109+
A list of successfully parsed :class:`I90Book` objects, sorted by date.
110+
"""
111+
files = archive.download(start=start, end=end)
112+
books: list[I90Book] = []
113+
for f in files:
114+
try:
115+
books.append(cls(f))
116+
except Exception as e:
117+
logger.warning("Failed to parse %s: %s", f.name, e)
118+
return books
119+
86120
def __repr__(self) -> str:
87121
return f"<I90Book {self.path.name} sheets={len(self.sheets)}>"
88122

@@ -192,7 +226,7 @@ def _preprocess(self) -> pd.DataFrame:
192226
columns_datetime = base_date + pd.to_timedelta(time_deltas, unit="m")
193227
columns_datetime = pd.DatetimeIndex(columns_datetime).tz_localize(
194228
"Europe/Madrid", ambiguous="infer"
195-
).tz_convert("UTC")
229+
)
196230

197231
data = pd.DataFrame(self.rows[idx + 1 :], columns=columns)
198232

0 commit comments

Comments
 (0)