nordic-balancing 0.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.
- nordic_balancing-0.1.0/LICENSE +21 -0
- nordic_balancing-0.1.0/PKG-INFO +168 -0
- nordic_balancing-0.1.0/README.md +144 -0
- nordic_balancing-0.1.0/pyproject.toml +85 -0
- nordic_balancing-0.1.0/pyproject.toml.orig +76 -0
- nordic_balancing-0.1.0/src/nordic_balancing/__init__.py +56 -0
- nordic_balancing-0.1.0/src/nordic_balancing/_http.py +118 -0
- nordic_balancing-0.1.0/src/nordic_balancing/_validate.py +33 -0
- nordic_balancing-0.1.0/src/nordic_balancing/changes.py +104 -0
- nordic_balancing-0.1.0/src/nordic_balancing/errors.py +19 -0
- nordic_balancing-0.1.0/src/nordic_balancing/frame.py +68 -0
- nordic_balancing-0.1.0/src/nordic_balancing/models.py +145 -0
- nordic_balancing-0.1.0/src/nordic_balancing/py.typed +0 -0
- nordic_balancing-0.1.0/src/nordic_balancing/reconcile.py +245 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/__init__.py +9 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/energidataservice.py +246 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/entsoe.py +536 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/esett.py +216 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/fingrid.py +349 -0
- nordic_balancing-0.1.0/src/nordic_balancing/sources/svk.py +271 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Razikale365
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nordic-balancing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Nordic electricity balancing and imbalance data (Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E), normalised to 15-minute UTC intervals.
|
|
5
|
+
Keywords: energy,electricity,balancing,imbalance,mfrr,afrr,nordic,energinet,fingrid,esett,svenska-kraftnat,entsoe
|
|
6
|
+
Author: Razikale365
|
|
7
|
+
Author-email: Razikale365 <66191390+Razikale365@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: httpx>=0.27,<1
|
|
18
|
+
Requires-Dist: pandas>=2.1 ; extra == 'pandas'
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/Razikale365/nordic-balancing
|
|
21
|
+
Project-URL: Issues, https://github.com/Razikale365/nordic-balancing/issues
|
|
22
|
+
Provides-Extra: pandas
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# nordic-balancing
|
|
26
|
+
|
|
27
|
+
Python client for Nordic electricity **balancing** and **imbalance** data, normalised to
|
|
28
|
+
15-minute UTC intervals.
|
|
29
|
+
|
|
30
|
+
> **Status: alpha.** Releases are on [PyPI](https://pypi.org/project/nordic-balancing/).
|
|
31
|
+
> Every source is checked against its live API, but the API of this library may still
|
|
32
|
+
> change between 0.x versions. See [CHANGELOG.md](CHANGELOG.md).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install nordic-balancing # httpx is the only dependency
|
|
38
|
+
pip install "nordic-balancing[pandas]" # adds to_frame()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Python 3.12 or newer.
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
Nordic balancing data is spread across several transmission system operators and eSett,
|
|
46
|
+
each with its own API, authentication, time zone handling and resolution history. The
|
|
47
|
+
Nordic markets moved to 15-minute resolution in stages (imbalance settlement in 2023,
|
|
48
|
+
mFRR energy activation and intraday in 2025, day-ahead in October 2025), so the same
|
|
49
|
+
series can change resolution mid-history.
|
|
50
|
+
|
|
51
|
+
`nordic-balancing` aims to give one typed interface over these sources, with every series
|
|
52
|
+
in UTC on 15-minute intervals and a dated record of resolution and price-formula changes.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from datetime import UTC, datetime, timedelta
|
|
58
|
+
|
|
59
|
+
from nordic_balancing import BiddingZone, ESettClient, EnergiDataServiceClient
|
|
60
|
+
|
|
61
|
+
start = datetime(2026, 1, 15, tzinfo=UTC)
|
|
62
|
+
with EnergiDataServiceClient() as eds:
|
|
63
|
+
prices = eds.imbalance_prices(start, start + timedelta(hours=1), [BiddingZone.DK1])
|
|
64
|
+
|
|
65
|
+
with ESettClient() as esett:
|
|
66
|
+
nordic_prices = esett.imbalance_prices(start, start + timedelta(hours=1)) # all 12 zones
|
|
67
|
+
|
|
68
|
+
for p in prices:
|
|
69
|
+
print(p.start, p.zone, p.imbalance_price_eur, p.dominating_direction)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
With the `pandas` extra, `to_frame` turns any list of records into a DataFrame: one row
|
|
73
|
+
per record, one column per field except `raw`, `start` in UTC, `None` as `NaN`, and
|
|
74
|
+
directions as nullable integers.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from nordic_balancing import to_frame
|
|
78
|
+
|
|
79
|
+
frame = to_frame(nordic_prices).set_index(["start", "zone"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every query takes timezone-aware datetimes and returns intervals starting in
|
|
83
|
+
`[start, end)`, keyed by their UTC start. A price of `None` means the source has
|
|
84
|
+
not published it yet or does not provide it. `resolution` retains the source's
|
|
85
|
+
publication interval; historical hourly values are expanded into four quarters.
|
|
86
|
+
`raw` is a read-only mapping over a deep copy of the source record, private to
|
|
87
|
+
that record; only its top level rejects writes, so treat nested values as read-only too.
|
|
88
|
+
|
|
89
|
+
Set `FINGRID_API_KEY` to a free key from https://developer-data.fingrid.fi.
|
|
90
|
+
Use `with FingridClient() as fingrid:` after importing it from `nordic_balancing`.
|
|
91
|
+
Call `fingrid.imbalance_prices(start, end)` for FI prices, or `series(dataset_id, start, end)`.
|
|
92
|
+
`series()` expands hourly records only for datasets 319, 244, 106 and 369; any other
|
|
93
|
+
dataset must be 15-minute, and an hourly record raises `SourceError`.
|
|
94
|
+
|
|
95
|
+
Svenska kraftnät needs no key.
|
|
96
|
+
Call `SvKClient().capacity_market(ReserveProduct.MFRR, start, end)` for SE1–SE4 capacity prices and volumes.
|
|
97
|
+
|
|
98
|
+
## Data contracts
|
|
99
|
+
|
|
100
|
+
Every client enforces the same contracts; a violation raises `SourceError`.
|
|
101
|
+
|
|
102
|
+
- **C1 Window.** Queries take aware datetimes and return exactly the intervals
|
|
103
|
+
whose start is in `[start, end)`, at any sub-second precision of `start`/`end`.
|
|
104
|
+
- **C2 Pagination.** Rows fetched must equal the total the source reported; a
|
|
105
|
+
missing or invalid total, a short or empty page before the total is reached,
|
|
106
|
+
or a mismatch is an error.
|
|
107
|
+
- **C3 Duplicates.** Two source records for the same series key (zone and
|
|
108
|
+
interval start, plus product and direction for SvK) are an error, even when
|
|
109
|
+
the values are equal. ENTSO-E is the exception: an interval published under
|
|
110
|
+
several price categories with one equal price collapses to that price, with
|
|
111
|
+
every category kept in `raw`; unequal prices, or equal prices at different
|
|
112
|
+
resolutions, raise.
|
|
113
|
+
- **C4 Missing vs unpublished.** An interval the source did not return is absent
|
|
114
|
+
from the result; a record returned with a null value has that field set to
|
|
115
|
+
`None`. Clients never fill gaps.
|
|
116
|
+
- **C5 Numbers.** Values must be JSON numbers (`bool` rejected), finite and
|
|
117
|
+
convertible to `float`.
|
|
118
|
+
- **C6 Outside window.** Records outside the requested query window are an error.
|
|
119
|
+
- **C7 Resolution.** `resolution` is the source's publication interval; hourly
|
|
120
|
+
records are repeated across four quarters. Sources can label the same price
|
|
121
|
+
differently: ENTSO-E kept `PT60M` for SE and NO until 2025-03-18T00:00Z, eSett
|
|
122
|
+
switched to 15 minutes on 2023-05-21T22:00Z (see
|
|
123
|
+
[docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md)).
|
|
124
|
+
- **C8 Transport.** Network and protocol failures surface as `SourceError`, never as
|
|
125
|
+
raw `httpx` exceptions. HTTP 429/503 are retried, then raise `RateLimitError`, which
|
|
126
|
+
is raised at once when `Retry-After` exceeds 300 s. HTTP 502/504, timeouts and
|
|
127
|
+
dropped connections are retried within the same `max_retries` budget, then raise
|
|
128
|
+
`SourceError`.
|
|
129
|
+
|
|
130
|
+
`reconcile_imbalance_prices` reports differences between two sources; it never merges
|
|
131
|
+
series or picks a source. Zones present in only one input are listed in
|
|
132
|
+
`primary_only_zones` / `reference_only_zones` rather than dropped silently.
|
|
133
|
+
|
|
134
|
+
See [docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md) for provider verification
|
|
135
|
+
status, observed API behaviour and known limitations.
|
|
136
|
+
|
|
137
|
+
## Sources
|
|
138
|
+
|
|
139
|
+
| Source | Client | Data | Access | Verified against the live API |
|
|
140
|
+
|---|---|---|---|---|
|
|
141
|
+
| Energinet — Energi Data Service | `EnergiDataServiceClient` | DK1/DK2 imbalance prices and components, from 2025-03-04 | free, no key | yes |
|
|
142
|
+
| Fingrid | `FingridClient` | FI imbalance prices, mFRR up/down prices, dominating direction; any 15-minute dataset via `series()` | free API key | yes |
|
|
143
|
+
| Svenska kraftnät | `SvKClient` | SE1–SE4 mFRR and aFRR capacity market prices and volumes (hourly, expanded to 15 minutes) | free (CC-BY-4.0) | yes |
|
|
144
|
+
| eSett | `ESettClient` | Imbalance prices, all 12 Nordic zones, from 2021-10-31T23:00Z | free, no key | yes |
|
|
145
|
+
| ENTSO-E Transparency Platform | `EntsoeClient` | Imbalance prices (A85), all 12 Nordic zones, from 2021-10-31T23:00Z | free token (`ENTSOE_API_KEY`) | yes |
|
|
146
|
+
|
|
147
|
+
Not covered yet: Energi Data Service mFRR/aFRR datasets, and activated balancing energy
|
|
148
|
+
volumes. Out of scope: generic day-ahead price clients and mFRR bid submission — other
|
|
149
|
+
projects already cover those.
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv sync
|
|
155
|
+
uv run pytest
|
|
156
|
+
uv run ruff check .
|
|
157
|
+
uv run ruff format --check .
|
|
158
|
+
uv run mypy
|
|
159
|
+
uv run pytest -m live # calls the real APIs; not run by default
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Licence
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](LICENSE). Data retrieved through this library remains subject to each
|
|
165
|
+
source's own terms of use.
|
|
166
|
+
|
|
167
|
+
This is an independent project. It is not affiliated with, endorsed by or supported by
|
|
168
|
+
Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E or any other data provider.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# nordic-balancing
|
|
2
|
+
|
|
3
|
+
Python client for Nordic electricity **balancing** and **imbalance** data, normalised to
|
|
4
|
+
15-minute UTC intervals.
|
|
5
|
+
|
|
6
|
+
> **Status: alpha.** Releases are on [PyPI](https://pypi.org/project/nordic-balancing/).
|
|
7
|
+
> Every source is checked against its live API, but the API of this library may still
|
|
8
|
+
> change between 0.x versions. See [CHANGELOG.md](CHANGELOG.md).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install nordic-balancing # httpx is the only dependency
|
|
14
|
+
pip install "nordic-balancing[pandas]" # adds to_frame()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.12 or newer.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Nordic balancing data is spread across several transmission system operators and eSett,
|
|
22
|
+
each with its own API, authentication, time zone handling and resolution history. The
|
|
23
|
+
Nordic markets moved to 15-minute resolution in stages (imbalance settlement in 2023,
|
|
24
|
+
mFRR energy activation and intraday in 2025, day-ahead in October 2025), so the same
|
|
25
|
+
series can change resolution mid-history.
|
|
26
|
+
|
|
27
|
+
`nordic-balancing` aims to give one typed interface over these sources, with every series
|
|
28
|
+
in UTC on 15-minute intervals and a dated record of resolution and price-formula changes.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from datetime import UTC, datetime, timedelta
|
|
34
|
+
|
|
35
|
+
from nordic_balancing import BiddingZone, ESettClient, EnergiDataServiceClient
|
|
36
|
+
|
|
37
|
+
start = datetime(2026, 1, 15, tzinfo=UTC)
|
|
38
|
+
with EnergiDataServiceClient() as eds:
|
|
39
|
+
prices = eds.imbalance_prices(start, start + timedelta(hours=1), [BiddingZone.DK1])
|
|
40
|
+
|
|
41
|
+
with ESettClient() as esett:
|
|
42
|
+
nordic_prices = esett.imbalance_prices(start, start + timedelta(hours=1)) # all 12 zones
|
|
43
|
+
|
|
44
|
+
for p in prices:
|
|
45
|
+
print(p.start, p.zone, p.imbalance_price_eur, p.dominating_direction)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
With the `pandas` extra, `to_frame` turns any list of records into a DataFrame: one row
|
|
49
|
+
per record, one column per field except `raw`, `start` in UTC, `None` as `NaN`, and
|
|
50
|
+
directions as nullable integers.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from nordic_balancing import to_frame
|
|
54
|
+
|
|
55
|
+
frame = to_frame(nordic_prices).set_index(["start", "zone"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every query takes timezone-aware datetimes and returns intervals starting in
|
|
59
|
+
`[start, end)`, keyed by their UTC start. A price of `None` means the source has
|
|
60
|
+
not published it yet or does not provide it. `resolution` retains the source's
|
|
61
|
+
publication interval; historical hourly values are expanded into four quarters.
|
|
62
|
+
`raw` is a read-only mapping over a deep copy of the source record, private to
|
|
63
|
+
that record; only its top level rejects writes, so treat nested values as read-only too.
|
|
64
|
+
|
|
65
|
+
Set `FINGRID_API_KEY` to a free key from https://developer-data.fingrid.fi.
|
|
66
|
+
Use `with FingridClient() as fingrid:` after importing it from `nordic_balancing`.
|
|
67
|
+
Call `fingrid.imbalance_prices(start, end)` for FI prices, or `series(dataset_id, start, end)`.
|
|
68
|
+
`series()` expands hourly records only for datasets 319, 244, 106 and 369; any other
|
|
69
|
+
dataset must be 15-minute, and an hourly record raises `SourceError`.
|
|
70
|
+
|
|
71
|
+
Svenska kraftnät needs no key.
|
|
72
|
+
Call `SvKClient().capacity_market(ReserveProduct.MFRR, start, end)` for SE1–SE4 capacity prices and volumes.
|
|
73
|
+
|
|
74
|
+
## Data contracts
|
|
75
|
+
|
|
76
|
+
Every client enforces the same contracts; a violation raises `SourceError`.
|
|
77
|
+
|
|
78
|
+
- **C1 Window.** Queries take aware datetimes and return exactly the intervals
|
|
79
|
+
whose start is in `[start, end)`, at any sub-second precision of `start`/`end`.
|
|
80
|
+
- **C2 Pagination.** Rows fetched must equal the total the source reported; a
|
|
81
|
+
missing or invalid total, a short or empty page before the total is reached,
|
|
82
|
+
or a mismatch is an error.
|
|
83
|
+
- **C3 Duplicates.** Two source records for the same series key (zone and
|
|
84
|
+
interval start, plus product and direction for SvK) are an error, even when
|
|
85
|
+
the values are equal. ENTSO-E is the exception: an interval published under
|
|
86
|
+
several price categories with one equal price collapses to that price, with
|
|
87
|
+
every category kept in `raw`; unequal prices, or equal prices at different
|
|
88
|
+
resolutions, raise.
|
|
89
|
+
- **C4 Missing vs unpublished.** An interval the source did not return is absent
|
|
90
|
+
from the result; a record returned with a null value has that field set to
|
|
91
|
+
`None`. Clients never fill gaps.
|
|
92
|
+
- **C5 Numbers.** Values must be JSON numbers (`bool` rejected), finite and
|
|
93
|
+
convertible to `float`.
|
|
94
|
+
- **C6 Outside window.** Records outside the requested query window are an error.
|
|
95
|
+
- **C7 Resolution.** `resolution` is the source's publication interval; hourly
|
|
96
|
+
records are repeated across four quarters. Sources can label the same price
|
|
97
|
+
differently: ENTSO-E kept `PT60M` for SE and NO until 2025-03-18T00:00Z, eSett
|
|
98
|
+
switched to 15 minutes on 2023-05-21T22:00Z (see
|
|
99
|
+
[docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md)).
|
|
100
|
+
- **C8 Transport.** Network and protocol failures surface as `SourceError`, never as
|
|
101
|
+
raw `httpx` exceptions. HTTP 429/503 are retried, then raise `RateLimitError`, which
|
|
102
|
+
is raised at once when `Retry-After` exceeds 300 s. HTTP 502/504, timeouts and
|
|
103
|
+
dropped connections are retried within the same `max_retries` budget, then raise
|
|
104
|
+
`SourceError`.
|
|
105
|
+
|
|
106
|
+
`reconcile_imbalance_prices` reports differences between two sources; it never merges
|
|
107
|
+
series or picks a source. Zones present in only one input are listed in
|
|
108
|
+
`primary_only_zones` / `reference_only_zones` rather than dropped silently.
|
|
109
|
+
|
|
110
|
+
See [docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md) for provider verification
|
|
111
|
+
status, observed API behaviour and known limitations.
|
|
112
|
+
|
|
113
|
+
## Sources
|
|
114
|
+
|
|
115
|
+
| Source | Client | Data | Access | Verified against the live API |
|
|
116
|
+
|---|---|---|---|---|
|
|
117
|
+
| Energinet — Energi Data Service | `EnergiDataServiceClient` | DK1/DK2 imbalance prices and components, from 2025-03-04 | free, no key | yes |
|
|
118
|
+
| Fingrid | `FingridClient` | FI imbalance prices, mFRR up/down prices, dominating direction; any 15-minute dataset via `series()` | free API key | yes |
|
|
119
|
+
| Svenska kraftnät | `SvKClient` | SE1–SE4 mFRR and aFRR capacity market prices and volumes (hourly, expanded to 15 minutes) | free (CC-BY-4.0) | yes |
|
|
120
|
+
| eSett | `ESettClient` | Imbalance prices, all 12 Nordic zones, from 2021-10-31T23:00Z | free, no key | yes |
|
|
121
|
+
| ENTSO-E Transparency Platform | `EntsoeClient` | Imbalance prices (A85), all 12 Nordic zones, from 2021-10-31T23:00Z | free token (`ENTSOE_API_KEY`) | yes |
|
|
122
|
+
|
|
123
|
+
Not covered yet: Energi Data Service mFRR/aFRR datasets, and activated balancing energy
|
|
124
|
+
volumes. Out of scope: generic day-ahead price clients and mFRR bid submission — other
|
|
125
|
+
projects already cover those.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv sync
|
|
131
|
+
uv run pytest
|
|
132
|
+
uv run ruff check .
|
|
133
|
+
uv run ruff format --check .
|
|
134
|
+
uv run mypy
|
|
135
|
+
uv run pytest -m live # calls the real APIs; not run by default
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Licence
|
|
139
|
+
|
|
140
|
+
MIT — see [LICENSE](LICENSE). Data retrieved through this library remains subject to each
|
|
141
|
+
source's own terms of use.
|
|
142
|
+
|
|
143
|
+
This is an independent project. It is not affiliated with, endorsed by or supported by
|
|
144
|
+
Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E or any other data provider.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nordic-balancing"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for Nordic electricity balancing and imbalance data (Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E), normalised to 15-minute UTC intervals."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"energy",
|
|
12
|
+
"electricity",
|
|
13
|
+
"balancing",
|
|
14
|
+
"imbalance",
|
|
15
|
+
"mfrr",
|
|
16
|
+
"afrr",
|
|
17
|
+
"nordic",
|
|
18
|
+
"energinet",
|
|
19
|
+
"fingrid",
|
|
20
|
+
"esett",
|
|
21
|
+
"svenska-kraftnat",
|
|
22
|
+
"entsoe",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Scientific/Engineering",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "Razikale365"
|
|
36
|
+
email = "66191390+Razikale365@users.noreply.github.com"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/Razikale365/nordic-balancing"
|
|
40
|
+
Issues = "https://github.com/Razikale365/nordic-balancing/issues"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
pandas = ["pandas>=2.1"]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.3",
|
|
48
|
+
"mypy>=1.11",
|
|
49
|
+
"ruff>=0.6",
|
|
50
|
+
"hypothesis>=6.168.5",
|
|
51
|
+
"pandas-stubs>=3.0.5.260914",
|
|
52
|
+
"pandas>=2.1",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = [
|
|
65
|
+
"E",
|
|
66
|
+
"F",
|
|
67
|
+
"W",
|
|
68
|
+
"I",
|
|
69
|
+
"B",
|
|
70
|
+
"UP",
|
|
71
|
+
"SIM",
|
|
72
|
+
"RUF",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
strict = true
|
|
77
|
+
files = [
|
|
78
|
+
"src",
|
|
79
|
+
"tests",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
testpaths = ["tests"]
|
|
84
|
+
addopts = "-ra -m 'not live'"
|
|
85
|
+
markers = ["live: calls real data source APIs (run with: pytest -m live)"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nordic-balancing"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for Nordic electricity balancing and imbalance data (Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E), normalised to 15-minute UTC intervals."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Razikale365", email = "66191390+Razikale365@users.noreply.github.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27,<1",
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"energy",
|
|
17
|
+
"electricity",
|
|
18
|
+
"balancing",
|
|
19
|
+
"imbalance",
|
|
20
|
+
"mfrr",
|
|
21
|
+
"afrr",
|
|
22
|
+
"nordic",
|
|
23
|
+
"energinet",
|
|
24
|
+
"fingrid",
|
|
25
|
+
"esett",
|
|
26
|
+
"svenska-kraftnat",
|
|
27
|
+
"entsoe",
|
|
28
|
+
]
|
|
29
|
+
classifiers = [
|
|
30
|
+
"Development Status :: 3 - Alpha",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Intended Audience :: Science/Research",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Scientific/Engineering",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/Razikale365/nordic-balancing"
|
|
41
|
+
Issues = "https://github.com/Razikale365/nordic-balancing/issues"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
pandas = [
|
|
45
|
+
"pandas>=2.1",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.3",
|
|
51
|
+
"mypy>=1.11",
|
|
52
|
+
"ruff>=0.6",
|
|
53
|
+
"hypothesis>=6.168.5",
|
|
54
|
+
"pandas-stubs>=3.0.5.260914",
|
|
55
|
+
"pandas>=2.1",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
60
|
+
build-backend = "uv_build"
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 100
|
|
64
|
+
target-version = "py312"
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
strict = true
|
|
71
|
+
files = ["src", "tests"]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
addopts = "-ra -m 'not live'"
|
|
76
|
+
markers = ["live: calls real data source APIs (run with: pytest -m live)"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Nordic electricity balancing and imbalance data, normalised to 15-minute UTC intervals."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from nordic_balancing.changes import CHANGES, MarketChange, changes_between
|
|
6
|
+
from nordic_balancing.errors import NordicBalancingError, RateLimitError, SourceError
|
|
7
|
+
from nordic_balancing.frame import to_frame
|
|
8
|
+
from nordic_balancing.models import (
|
|
9
|
+
INTERVAL,
|
|
10
|
+
BiddingZone,
|
|
11
|
+
Direction,
|
|
12
|
+
ImbalancePrice,
|
|
13
|
+
ReserveCapacity,
|
|
14
|
+
ReserveProduct,
|
|
15
|
+
)
|
|
16
|
+
from nordic_balancing.reconcile import (
|
|
17
|
+
Divergence,
|
|
18
|
+
DivergenceKind,
|
|
19
|
+
ReconciliationReport,
|
|
20
|
+
reconcile_imbalance_prices,
|
|
21
|
+
)
|
|
22
|
+
from nordic_balancing.sources import (
|
|
23
|
+
EnergiDataServiceClient,
|
|
24
|
+
EntsoeClient,
|
|
25
|
+
ESettClient,
|
|
26
|
+
FingridClient,
|
|
27
|
+
SvKClient,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = version("nordic-balancing")
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CHANGES",
|
|
34
|
+
"INTERVAL",
|
|
35
|
+
"BiddingZone",
|
|
36
|
+
"Direction",
|
|
37
|
+
"Divergence",
|
|
38
|
+
"DivergenceKind",
|
|
39
|
+
"ESettClient",
|
|
40
|
+
"EnergiDataServiceClient",
|
|
41
|
+
"EntsoeClient",
|
|
42
|
+
"FingridClient",
|
|
43
|
+
"ImbalancePrice",
|
|
44
|
+
"MarketChange",
|
|
45
|
+
"NordicBalancingError",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"ReconciliationReport",
|
|
48
|
+
"ReserveCapacity",
|
|
49
|
+
"ReserveProduct",
|
|
50
|
+
"SourceError",
|
|
51
|
+
"SvKClient",
|
|
52
|
+
"__version__",
|
|
53
|
+
"changes_between",
|
|
54
|
+
"reconcile_imbalance_prices",
|
|
55
|
+
"to_frame",
|
|
56
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shared bounded retries so sources handle throttling consistently."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from nordic_balancing.errors import RateLimitError, SourceError
|
|
10
|
+
|
|
11
|
+
# Throttling: exhausting the retries raises RateLimitError.
|
|
12
|
+
_RATE_LIMIT_STATUSES = frozenset({429, 503})
|
|
13
|
+
# Transient gateway failures: retried, then reported as an ordinary HTTP error.
|
|
14
|
+
_TRANSIENT_STATUSES = frozenset({502, 504})
|
|
15
|
+
_TRANSIENT_ERRORS = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
|
|
16
|
+
_DEFAULT_RETRY_AFTER = 10.0
|
|
17
|
+
# A longer requested wait is not slept through: RateLimitError reports it instead.
|
|
18
|
+
_MAX_RETRY_AFTER = 300.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_json(
|
|
22
|
+
http: httpx.Client,
|
|
23
|
+
base_url: str,
|
|
24
|
+
path: str,
|
|
25
|
+
params: Mapping[str, str] | list[tuple[str, str]],
|
|
26
|
+
*,
|
|
27
|
+
source: str,
|
|
28
|
+
max_retries: int,
|
|
29
|
+
sleep: Callable[[float], None],
|
|
30
|
+
) -> Any:
|
|
31
|
+
response = _request(
|
|
32
|
+
http,
|
|
33
|
+
base_url,
|
|
34
|
+
path,
|
|
35
|
+
params,
|
|
36
|
+
source=source,
|
|
37
|
+
max_retries=max_retries,
|
|
38
|
+
sleep=sleep,
|
|
39
|
+
passthrough_statuses=frozenset(),
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
return response.json()
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise SourceError(f"{source}: response for {path} is not JSON") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_bytes(
|
|
48
|
+
http: httpx.Client,
|
|
49
|
+
base_url: str,
|
|
50
|
+
path: str,
|
|
51
|
+
params: Mapping[str, str] | list[tuple[str, str]],
|
|
52
|
+
*,
|
|
53
|
+
source: str,
|
|
54
|
+
max_retries: int,
|
|
55
|
+
sleep: Callable[[float], None],
|
|
56
|
+
passthrough_statuses: frozenset[int] = frozenset(),
|
|
57
|
+
) -> tuple[bytes, str]:
|
|
58
|
+
response = _request(
|
|
59
|
+
http,
|
|
60
|
+
base_url,
|
|
61
|
+
path,
|
|
62
|
+
params,
|
|
63
|
+
source=source,
|
|
64
|
+
max_retries=max_retries,
|
|
65
|
+
sleep=sleep,
|
|
66
|
+
passthrough_statuses=passthrough_statuses,
|
|
67
|
+
)
|
|
68
|
+
return response.content, response.headers.get("content-type", "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _request(
|
|
72
|
+
http: httpx.Client,
|
|
73
|
+
base_url: str,
|
|
74
|
+
path: str,
|
|
75
|
+
params: Mapping[str, str] | list[tuple[str, str]],
|
|
76
|
+
*,
|
|
77
|
+
source: str,
|
|
78
|
+
max_retries: int,
|
|
79
|
+
sleep: Callable[[float], None],
|
|
80
|
+
passthrough_statuses: frozenset[int],
|
|
81
|
+
) -> httpx.Response:
|
|
82
|
+
query_params = params if isinstance(params, Mapping) else tuple(params)
|
|
83
|
+
for attempt in range(max_retries + 1):
|
|
84
|
+
last = attempt == max_retries
|
|
85
|
+
try:
|
|
86
|
+
response = http.get(f"{base_url}{path}", params=query_params)
|
|
87
|
+
except _TRANSIENT_ERRORS:
|
|
88
|
+
# Timeouts and dropped connections are retried; callers wrap the last one.
|
|
89
|
+
if last:
|
|
90
|
+
raise
|
|
91
|
+
sleep(_DEFAULT_RETRY_AFTER)
|
|
92
|
+
continue
|
|
93
|
+
if response.status_code in _RATE_LIMIT_STATUSES:
|
|
94
|
+
retry_after = _retry_after(response)
|
|
95
|
+
if last or (retry_after is not None and retry_after > _MAX_RETRY_AFTER):
|
|
96
|
+
raise RateLimitError(source, retry_after)
|
|
97
|
+
sleep(retry_after if retry_after is not None else _DEFAULT_RETRY_AFTER)
|
|
98
|
+
elif response.status_code in _TRANSIENT_STATUSES and not last:
|
|
99
|
+
sleep(_DEFAULT_RETRY_AFTER)
|
|
100
|
+
else:
|
|
101
|
+
break
|
|
102
|
+
if response.is_error and response.status_code not in passthrough_statuses:
|
|
103
|
+
raise SourceError(
|
|
104
|
+
f"{source}: HTTP {response.status_code} for {path}: {response.text[:200]}"
|
|
105
|
+
)
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
110
|
+
header = response.headers.get("Retry-After")
|
|
111
|
+
if header is None:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
seconds = float(header)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
# "inf" and "nan" parse as floats but are not usable delays.
|
|
118
|
+
return max(seconds, 0.0) if math.isfinite(seconds) else None
|