pse-opendata 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,110 @@
1
+ """pse-opendata: unofficial client for the public data of PSE, the Polish transmission system operator.
2
+
3
+ Quick start:
4
+ import pse_opendata as pse
5
+ rce = pse.rce("2025-01-01", "2025-01-31") # RCE price, 15-minute rows
6
+ sys = pse.get("his-wlk-cal", "2025-06-01", fields=["demand", "wi", "pv"])
7
+ demand = pse.history("demand", "2019-01-01", "2025-12-31") # legacy export + API, hourly
8
+
9
+ Module-level functions use a shared default :class:`Client` (default cache directory, see
10
+ :mod:`pse_opendata.cache`); pass ``client=Client(...)`` to any of them to change the settings.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any
17
+
18
+ import pandas as pd
19
+
20
+ from pse_opendata._version import __version__
21
+ from pse_opendata.catalog import LEGACY_REPORTS, REPORTS, Field, LegacyReport, Report, get_legacy_report, get_report
22
+ from pse_opendata.client import Client, DateLike, MonthLoad
23
+ from pse_opendata.exceptions import (
24
+ CacheMissError,
25
+ LegacyFormatError,
26
+ PseApiError,
27
+ PseError,
28
+ UnknownFieldError,
29
+ UnknownReportError,
30
+ )
31
+ from pse_opendata.frames import TIME_COLUMNS, to_hourly
32
+ from pse_opendata.stitch import splice_factor
33
+
34
+ logging.getLogger("pse_opendata").addHandler(logging.NullHandler())
35
+
36
+ __all__ = [
37
+ "LEGACY_REPORTS", "REPORTS", "TIME_COLUMNS", "CacheMissError", "Client", "Field", "LegacyFormatError",
38
+ "LegacyReport", "MonthLoad", "PseApiError", "PseError", "Report", "UnknownFieldError", "UnknownReportError",
39
+ "__version__", "balancing_capacity", "default_client", "fundamentals", "get", "get_legacy_report", "get_report",
40
+ "history", "imbalance_prices", "legacy", "rce", "reports", "sdac", "splice_factor", "to_hourly",
41
+ ]
42
+
43
+ _default: Client | None = None
44
+
45
+
46
+ def default_client() -> Client:
47
+ """The shared client used by the module-level functions (created on first use)."""
48
+ global _default
49
+ if _default is None:
50
+ _default = Client()
51
+ return _default
52
+
53
+
54
+ def get(report: str, start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
55
+ **kwargs: Any) -> pd.DataFrame:
56
+ """Data of a report of the reports API; see :meth:`Client.get`."""
57
+ return (client or default_client()).get(report, start, end, **kwargs)
58
+
59
+
60
+ def legacy(code: str, start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
61
+ **kwargs: Any) -> pd.DataFrame:
62
+ """Hourly data of the legacy CSV export; see :meth:`Client.legacy`."""
63
+ return (client or default_client()).legacy(code, start, end, **kwargs)
64
+
65
+
66
+ def history(series: str, start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
67
+ **kwargs: Any) -> pd.DataFrame:
68
+ """Hourly series stitched from the legacy export and the API; see :meth:`Client.history`."""
69
+ return (client or default_client()).history(series, start, end, **kwargs)
70
+
71
+
72
+ def rce(start: DateLike, end: DateLike | None = None, *, client: Client | None = None, **kwargs: Any) -> pd.DataFrame:
73
+ """RCE price (``rce-pln``); see :meth:`Client.rce`."""
74
+ return (client or default_client()).rce(start, end, **kwargs)
75
+
76
+
77
+ def sdac(start: DateLike, end: DateLike | None = None, *, client: Client | None = None, **kwargs: Any) -> pd.DataFrame:
78
+ """SDAC price for Poland (``csdac-pln``); see :meth:`Client.sdac`."""
79
+ return (client or default_client()).sdac(start, end, **kwargs)
80
+
81
+
82
+ def fundamentals(start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
83
+ **kwargs: Any) -> pd.DataFrame:
84
+ """Demand, generation and exchange (``his-wlk-cal``); see :meth:`Client.fundamentals`."""
85
+ return (client or default_client()).fundamentals(start, end, **kwargs)
86
+
87
+
88
+ def imbalance_prices(start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
89
+ **kwargs: Any) -> pd.DataFrame:
90
+ """Imbalance and balancing energy prices (``crb-rozl``); see :meth:`Client.imbalance_prices`."""
91
+ return (client or default_client()).imbalance_prices(start, end, **kwargs)
92
+
93
+
94
+ def balancing_capacity(start: DateLike, end: DateLike | None = None, *, client: Client | None = None,
95
+ **kwargs: Any) -> pd.DataFrame:
96
+ """Balancing capacity prices and volumes; see :meth:`Client.balancing_capacity`."""
97
+ return (client or default_client()).balancing_capacity(start, end, **kwargs)
98
+
99
+
100
+ def reports(legacy: bool = False) -> pd.DataFrame:
101
+ """The catalogue as a frame: one row per report of the reports API (or of the legacy export)."""
102
+ if legacy:
103
+ rows = [{"code": r.code, "title": r.title, "title_pl": r.title_pl, "resolution": r.resolution,
104
+ "first_date": r.first_date, "last_date": r.last_date, "fields": ", ".join(r.field_names),
105
+ "page": r.page} for r in LEGACY_REPORTS.values()]
106
+ else:
107
+ rows = [{"name": r.name, "title": r.title, "title_pl": r.title_pl, "group": r.group,
108
+ "resolution": r.resolution, "first_date": r.first_date, "revision_days": r.revision_days,
109
+ "fields": ", ".join(r.field_names), "verified": r.verified, "url": r.url} for r in REPORTS.values()]
110
+ return pd.DataFrame(rows)
@@ -0,0 +1,3 @@
1
+ from pse_opendata.cli import main
2
+
3
+ raise SystemExit(main())
pse_opendata/_http.py ADDED
@@ -0,0 +1,126 @@
1
+ """HTTP with retries, exponential backoff and a minimum pause between requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any, TypeVar
9
+
10
+ import requests
11
+
12
+ from pse_opendata._version import __version__
13
+ from pse_opendata.exceptions import PseApiError, PseError
14
+
15
+ log = logging.getLogger("pse_opendata")
16
+
17
+ USER_AGENT = f"pse-opendata/{__version__} (+https://github.com/kwwaleszczynski-del/pse-opendata)"
18
+ # network failures worth another attempt; other RequestException subclasses (bad URL, ...) are raised at once
19
+ RETRY_ERRORS = (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError,
20
+ requests.exceptions.ContentDecodingError)
21
+ T = TypeVar("T")
22
+
23
+
24
+ def _retry_after(response: requests.Response) -> float | None:
25
+ value = response.headers.get("Retry-After")
26
+ try:
27
+ return float(value) if value is not None else None
28
+ except ValueError:
29
+ return None
30
+
31
+
32
+ def _error_message(response: requests.Response) -> tuple[str, str | None]:
33
+ """Message and code of an error response; the reports API answers ``{"error": {"code", "message"}}``."""
34
+ try:
35
+ err = response.json().get("error", {})
36
+ return str(err.get("message") or response.reason), err.get("code")
37
+ except (ValueError, AttributeError):
38
+ return f"{response.reason or 'HTTP error'}", None
39
+
40
+
41
+ class HttpClient:
42
+ """GET requests with retries on network errors (also a connection dropped mid-body), HTTP 429 and 5xx.
43
+
44
+ Waits ``backoff * 2**attempt`` seconds (at most ``max_backoff``) between attempts, or what ``Retry-After`` asks
45
+ for. ``min_interval`` is the pause between the end of one request and the start of the next one.
46
+ ``sleep`` and ``clock`` can be replaced in tests.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ session: requests.Session | None = None,
52
+ *,
53
+ timeout: float = 60.0,
54
+ retries: int = 4,
55
+ backoff: float = 2.0,
56
+ max_backoff: float = 120.0,
57
+ min_interval: float = 0.0,
58
+ user_agent: str = USER_AGENT,
59
+ sleep: Callable[[float], None] = time.sleep,
60
+ clock: Callable[[], float] = time.monotonic,
61
+ ):
62
+ self.session = session if session is not None else requests.Session()
63
+ self.timeout = timeout
64
+ self.retries = retries
65
+ self.backoff = backoff
66
+ self.max_backoff = max_backoff
67
+ self.min_interval = min_interval
68
+ self.user_agent = user_agent
69
+ self._sleep = sleep
70
+ self._clock = clock
71
+ self._last_done: float | None = None
72
+
73
+ def _wait_for_slot(self) -> None:
74
+ if self._last_done is None or self.min_interval <= 0:
75
+ return
76
+ wait = self._last_done + self.min_interval - self._clock()
77
+ if wait > 0:
78
+ self._sleep(wait)
79
+
80
+ def _delay(self, attempt: int, response: requests.Response | None) -> float:
81
+ hinted = _retry_after(response) if response is not None else None
82
+ if hinted is not None:
83
+ return min(hinted, self.max_backoff)
84
+ return min(self.backoff * 2**attempt, self.max_backoff)
85
+
86
+ def get(self, url: str, params: dict[str, Any] | None = None,
87
+ decode: Callable[[requests.Response], T] | None = None) -> Any:
88
+ """GET ``url`` and return ``decode(response)`` (the response itself without ``decode``).
89
+
90
+ A plain ``ValueError`` from ``decode`` (e.g. truncated JSON) is retried like a network error; errors of
91
+ this package raised by ``decode`` are not. Raises :class:`PseApiError` when the request finally fails or the
92
+ server answers a non-retryable error.
93
+ """
94
+ last_error: PseApiError | None = None
95
+ for attempt in range(self.retries + 1):
96
+ self._wait_for_slot()
97
+ response = None
98
+ try:
99
+ response = self.session.get(url, params=params, timeout=self.timeout,
100
+ headers={"User-Agent": self.user_agent})
101
+ except RETRY_ERRORS as e:
102
+ last_error = PseApiError(f"request failed: {e}", url=url)
103
+ except requests.RequestException as e:
104
+ raise PseApiError(f"request failed: {e}", url=url) from e
105
+ finally:
106
+ self._last_done = self._clock()
107
+ if response is not None:
108
+ status = response.status_code
109
+ if status >= 400:
110
+ message, code = _error_message(response)
111
+ last_error = PseApiError(f"HTTP {status} from {url}: {message}", status=status, code=code, url=url)
112
+ if status != 429 and status < 500:
113
+ raise last_error
114
+ else:
115
+ try:
116
+ return decode(response) if decode is not None else response
117
+ except PseError:
118
+ raise
119
+ except ValueError as e:
120
+ last_error = PseApiError(f"invalid response from {url}: {e}", status=status, url=url)
121
+ if attempt < self.retries:
122
+ delay = self._delay(attempt, response)
123
+ log.warning("%s; retry %d/%d in %.0f s", last_error, attempt + 1, self.retries, delay)
124
+ self._sleep(delay)
125
+ assert last_error is not None
126
+ raise last_error
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
pse_opendata/cache.py ADDED
@@ -0,0 +1,108 @@
1
+ """Monthly Parquet cache.
2
+
3
+ One file per report and calendar month (business dates): ``<cache_dir>/<source>/<report>/<YYYY-MM>.parquet``,
4
+ with ``source`` = ``api`` or ``legacy``. Each file stores when it was downloaded, which decides whether the month is
5
+ final (see :attr:`pse_opendata.catalog.Report.revision_days`). Files are written atomically.
6
+
7
+ The default directory is ``$PSE_OPENDATA_CACHE`` if set, otherwise the user cache directory:
8
+ ``%LOCALAPPDATA%\\pse-opendata\\cache`` on Windows, ``~/Library/Caches/pse-opendata`` on macOS and
9
+ ``$XDG_CACHE_HOME/pse-opendata`` (``~/.cache/pse-opendata``) elsewhere.
10
+
11
+ Usage:
12
+ cache = MonthCache(default_cache_dir())
13
+ cache.write("api", "rce-pln", date(2025, 1, 1), frame, fetched_utc=now)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from datetime import date, datetime
23
+ from pathlib import Path
24
+
25
+ import pandas as pd
26
+ import pyarrow as pa
27
+ import pyarrow.parquet as pq
28
+
29
+ from pse_opendata._version import __version__
30
+
31
+ META_KEY = b"pse_opendata"
32
+ SOURCES = ("api", "legacy")
33
+ DATETIME_COLUMNS = ["start_utc", "end_utc", "start_local", "end_local"]
34
+
35
+
36
+ def default_cache_dir() -> Path:
37
+ env = os.environ.get("PSE_OPENDATA_CACHE")
38
+ if env:
39
+ return Path(env)
40
+ if sys.platform == "win32":
41
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
42
+ return base / "pse-opendata" / "cache"
43
+ if sys.platform == "darwin":
44
+ return Path.home() / "Library" / "Caches" / "pse-opendata"
45
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pse-opendata"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class CachedMonth:
50
+ frame: pd.DataFrame
51
+ fetched_utc: datetime
52
+ first_day: date
53
+ last_day: date
54
+
55
+
56
+ class MonthCache:
57
+ def __init__(self, root: Path | str):
58
+ self.root = Path(root)
59
+
60
+ def path(self, source: str, report: str, month: date) -> Path:
61
+ return self.root / source / report / f"{month:%Y-%m}.parquet"
62
+
63
+ def read(self, source: str, report: str, month: date) -> CachedMonth | None:
64
+ """The cached month, or ``None`` when absent or unreadable."""
65
+ path = self.path(source, report, month)
66
+ if not path.exists():
67
+ return None
68
+ try:
69
+ table = pq.read_table(path)
70
+ meta = json.loads((table.schema.metadata or {})[META_KEY])
71
+ fetched_utc = datetime.fromisoformat(meta["fetched_utc"])
72
+ first_day = date.fromisoformat(meta["first_day"])
73
+ last_day = date.fromisoformat(meta["last_day"])
74
+ frame = table.to_pandas()
75
+ except (OSError, KeyError, TypeError, ValueError, pa.ArrowException):
76
+ return None
77
+ for col in DATETIME_COLUMNS:
78
+ if col in frame:
79
+ frame[col] = frame[col].dt.as_unit("ns")
80
+ return CachedMonth(frame=frame, fetched_utc=fetched_utc, first_day=first_day, last_day=last_day)
81
+
82
+ def write(self, source: str, report: str, month: date, frame: pd.DataFrame, *, fetched_utc: datetime,
83
+ first_day: date, last_day: date) -> Path:
84
+ """Store the frame of one month; ``first_day``..``last_day`` are the business dates it covers."""
85
+ path = self.path(source, report, month)
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ table = pa.Table.from_pandas(frame, preserve_index=False)
88
+ meta = {"fetched_utc": fetched_utc.isoformat(), "first_day": first_day.isoformat(),
89
+ "last_day": last_day.isoformat(), "report": report, "version": __version__}
90
+ table = table.replace_schema_metadata({**(table.schema.metadata or {}), META_KEY: json.dumps(meta)})
91
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
92
+ pq.write_table(table, tmp)
93
+ os.replace(tmp, path)
94
+ return path
95
+
96
+ def clear(self, source: str | None = None, report: str | None = None) -> int:
97
+ """Delete cached files (all, one source, or one report of a source); returns how many were deleted.
98
+
99
+ Only the ``api`` and ``legacy`` sub-folders are touched, so other files in the directory are safe.
100
+ """
101
+ sources = [source] if source else list(SOURCES)
102
+ files = []
103
+ for src in sources:
104
+ base = self.root / src / report if report else self.root / src
105
+ files += [f for f in base.glob("**/*.parquet") if f.is_file()] if base.exists() else []
106
+ for f in files:
107
+ f.unlink()
108
+ return len(files)