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.
- pse_opendata/__init__.py +110 -0
- pse_opendata/__main__.py +3 -0
- pse_opendata/_http.py +126 -0
- pse_opendata/_version.py +1 -0
- pse_opendata/cache.py +108 -0
- pse_opendata/catalog.py +590 -0
- pse_opendata/cli.py +150 -0
- pse_opendata/client.py +455 -0
- pse_opendata/exceptions.py +39 -0
- pse_opendata/frames.py +139 -0
- pse_opendata/legacy_csv.py +119 -0
- pse_opendata/py.typed +0 -0
- pse_opendata/stitch.py +75 -0
- pse_opendata-0.2.0.dist-info/METADATA +194 -0
- pse_opendata-0.2.0.dist-info/RECORD +19 -0
- pse_opendata-0.2.0.dist-info/WHEEL +5 -0
- pse_opendata-0.2.0.dist-info/entry_points.txt +2 -0
- pse_opendata-0.2.0.dist-info/licenses/LICENSE +21 -0
- pse_opendata-0.2.0.dist-info/top_level.txt +1 -0
pse_opendata/__init__.py
ADDED
|
@@ -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)
|
pse_opendata/__main__.py
ADDED
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
|
pse_opendata/_version.py
ADDED
|
@@ -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)
|