nordic-balancing 0.1.0a1__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.
@@ -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,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: nordic-balancing
3
+ Version: 0.1.0a1
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 :: 2 - Pre-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
18
+ Requires-Python: >=3.12
19
+ Project-URL: Homepage, https://github.com/Razikale365/nordic-balancing
20
+ Project-URL: Issues, https://github.com/Razikale365/nordic-balancing/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # nordic-balancing
24
+
25
+ Python client for Nordic electricity **balancing** and **imbalance** data, normalised to
26
+ 15-minute UTC intervals.
27
+
28
+ > **Status: pre-alpha.** Nothing is published yet; the API will change.
29
+
30
+ ## Why
31
+
32
+ Nordic balancing data is spread across several transmission system operators and eSett,
33
+ each with its own API, authentication, time zone handling and resolution history. The
34
+ Nordic markets moved to 15-minute resolution in stages (imbalance settlement in 2023,
35
+ mFRR energy activation and intraday in 2025, day-ahead in October 2025), so the same
36
+ series can change resolution mid-history.
37
+
38
+ `nordic-balancing` aims to give one typed interface over these sources, with every series
39
+ in UTC on 15-minute intervals and a dated record of resolution and price-formula changes.
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from datetime import UTC, datetime, timedelta
45
+
46
+ from nordic_balancing import BiddingZone, ESettClient, EnergiDataServiceClient
47
+
48
+ start = datetime(2026, 1, 15, tzinfo=UTC)
49
+ with EnergiDataServiceClient() as eds:
50
+ prices = eds.imbalance_prices(start, start + timedelta(hours=1), [BiddingZone.DK1])
51
+
52
+ with ESettClient() as esett:
53
+ nordic_prices = esett.imbalance_prices(start, start + timedelta(hours=1)) # all 12 zones
54
+
55
+ for p in prices:
56
+ print(p.start, p.zone, p.imbalance_price_eur, p.dominating_direction)
57
+ ```
58
+
59
+ Every query takes timezone-aware datetimes and returns intervals starting in
60
+ `[start, end)`, keyed by their UTC start. A price of `None` means the source has
61
+ not published it yet or does not provide it. `resolution` retains the source's
62
+ publication interval; historical hourly values are expanded into four quarters.
63
+ `raw` is a read-only copy of the source record.
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
+
69
+ Svenska kraftnät needs no key.
70
+ Call `SvKClient().capacity_market(ReserveProduct.MFRR, start, end)` for SE1–SE4 capacity prices and volumes.
71
+
72
+ ## Data contracts
73
+
74
+ Every client enforces the same contracts; a violation raises `SourceError`.
75
+
76
+ - **C1 Window.** Queries take aware datetimes and return exactly the intervals
77
+ whose start is in `[start, end)`, at any sub-second precision of `start`/`end`.
78
+ - **C2 Pagination.** Rows fetched must equal the total the source reported; a
79
+ missing or invalid total, a short or empty page before the total is reached,
80
+ or a mismatch is an error.
81
+ - **C3 Duplicates.** Two source records for the same series key (zone and
82
+ interval start, plus product and direction for SvK) are an error, even when
83
+ the values are equal.
84
+ - **C4 Missing vs unpublished.** An interval the source did not return is absent
85
+ from the result; a record returned with a null value has that field set to
86
+ `None`. Clients never fill gaps.
87
+ - **C5 Numbers.** Values must be JSON numbers (`bool` rejected), finite and
88
+ convertible to `float`.
89
+ - **C6 Outside window.** Records outside the requested query window are an error.
90
+ - **C7 Resolution.** `resolution` is the source's publication interval; hourly
91
+ records are repeated across four quarters.
92
+ - **C8 Transport.** Network and protocol failures surface as `SourceError`, never as
93
+ raw `httpx` exceptions (after the bounded retries for HTTP 429/503).
94
+
95
+ `reconcile_imbalance_prices` reports differences between two sources; it never merges
96
+ series or picks a source.
97
+
98
+ See [docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md) for provider verification
99
+ status, observed API behaviour and known limitations.
100
+
101
+ ## Planned sources
102
+
103
+ | Source | Data | Access |
104
+ |---|---|---|
105
+ | Energinet — Energi Data Service | DK1/DK2 imbalance prices and components (**available**); mFRR/aFRR datasets | free, no key |
106
+ | Fingrid | FI imbalance prices, mFRR price components and 15-minute series (**available**) | free API key |
107
+ | Svenska kraftnät | SE1–SE4 mFRR and aFRR capacity market prices and volumes (hourly, expanded to 15 minutes) (**available**) | free (CC-BY-4.0) |
108
+ | eSett | Imbalance prices, all 12 Nordic zones, from 2021-11-01 (**available**) | free, no key |
109
+ | ENTSO-E Transparency Platform | Imbalance prices (A85), all 12 Nordic zones (**available, not yet live-verified**) | free token (`ENTSOE_API_KEY`) |
110
+
111
+ Out of scope: generic day-ahead price clients and mFRR bid submission — other projects
112
+ already cover those.
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ uv sync
118
+ uv run pytest
119
+ uv run ruff check .
120
+ uv run ruff format --check .
121
+ uv run mypy
122
+ uv run pytest -m live # calls the real APIs; not run by default
123
+ ```
124
+
125
+ ## Licence
126
+
127
+ MIT — see [LICENSE](LICENSE). Data retrieved through this library remains subject to each
128
+ source's own terms of use.
129
+
130
+ This is an independent project. It is not affiliated with, endorsed by or supported by
131
+ Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E or any other data provider.
@@ -0,0 +1,109 @@
1
+ # nordic-balancing
2
+
3
+ Python client for Nordic electricity **balancing** and **imbalance** data, normalised to
4
+ 15-minute UTC intervals.
5
+
6
+ > **Status: pre-alpha.** Nothing is published yet; the API will change.
7
+
8
+ ## Why
9
+
10
+ Nordic balancing data is spread across several transmission system operators and eSett,
11
+ each with its own API, authentication, time zone handling and resolution history. The
12
+ Nordic markets moved to 15-minute resolution in stages (imbalance settlement in 2023,
13
+ mFRR energy activation and intraday in 2025, day-ahead in October 2025), so the same
14
+ series can change resolution mid-history.
15
+
16
+ `nordic-balancing` aims to give one typed interface over these sources, with every series
17
+ in UTC on 15-minute intervals and a dated record of resolution and price-formula changes.
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from datetime import UTC, datetime, timedelta
23
+
24
+ from nordic_balancing import BiddingZone, ESettClient, EnergiDataServiceClient
25
+
26
+ start = datetime(2026, 1, 15, tzinfo=UTC)
27
+ with EnergiDataServiceClient() as eds:
28
+ prices = eds.imbalance_prices(start, start + timedelta(hours=1), [BiddingZone.DK1])
29
+
30
+ with ESettClient() as esett:
31
+ nordic_prices = esett.imbalance_prices(start, start + timedelta(hours=1)) # all 12 zones
32
+
33
+ for p in prices:
34
+ print(p.start, p.zone, p.imbalance_price_eur, p.dominating_direction)
35
+ ```
36
+
37
+ Every query takes timezone-aware datetimes and returns intervals starting in
38
+ `[start, end)`, keyed by their UTC start. A price of `None` means the source has
39
+ not published it yet or does not provide it. `resolution` retains the source's
40
+ publication interval; historical hourly values are expanded into four quarters.
41
+ `raw` is a read-only copy of the source record.
42
+
43
+ Set `FINGRID_API_KEY` to a free key from https://developer-data.fingrid.fi.
44
+ Use `with FingridClient() as fingrid:` after importing it from `nordic_balancing`.
45
+ Call `fingrid.imbalance_prices(start, end)` for FI prices, or `series(dataset_id, start, end)`.
46
+
47
+ Svenska kraftnät needs no key.
48
+ Call `SvKClient().capacity_market(ReserveProduct.MFRR, start, end)` for SE1–SE4 capacity prices and volumes.
49
+
50
+ ## Data contracts
51
+
52
+ Every client enforces the same contracts; a violation raises `SourceError`.
53
+
54
+ - **C1 Window.** Queries take aware datetimes and return exactly the intervals
55
+ whose start is in `[start, end)`, at any sub-second precision of `start`/`end`.
56
+ - **C2 Pagination.** Rows fetched must equal the total the source reported; a
57
+ missing or invalid total, a short or empty page before the total is reached,
58
+ or a mismatch is an error.
59
+ - **C3 Duplicates.** Two source records for the same series key (zone and
60
+ interval start, plus product and direction for SvK) are an error, even when
61
+ the values are equal.
62
+ - **C4 Missing vs unpublished.** An interval the source did not return is absent
63
+ from the result; a record returned with a null value has that field set to
64
+ `None`. Clients never fill gaps.
65
+ - **C5 Numbers.** Values must be JSON numbers (`bool` rejected), finite and
66
+ convertible to `float`.
67
+ - **C6 Outside window.** Records outside the requested query window are an error.
68
+ - **C7 Resolution.** `resolution` is the source's publication interval; hourly
69
+ records are repeated across four quarters.
70
+ - **C8 Transport.** Network and protocol failures surface as `SourceError`, never as
71
+ raw `httpx` exceptions (after the bounded retries for HTTP 429/503).
72
+
73
+ `reconcile_imbalance_prices` reports differences between two sources; it never merges
74
+ series or picks a source.
75
+
76
+ See [docs/RELEASE_EVIDENCE.md](docs/RELEASE_EVIDENCE.md) for provider verification
77
+ status, observed API behaviour and known limitations.
78
+
79
+ ## Planned sources
80
+
81
+ | Source | Data | Access |
82
+ |---|---|---|
83
+ | Energinet — Energi Data Service | DK1/DK2 imbalance prices and components (**available**); mFRR/aFRR datasets | free, no key |
84
+ | Fingrid | FI imbalance prices, mFRR price components and 15-minute series (**available**) | free API key |
85
+ | Svenska kraftnät | SE1–SE4 mFRR and aFRR capacity market prices and volumes (hourly, expanded to 15 minutes) (**available**) | free (CC-BY-4.0) |
86
+ | eSett | Imbalance prices, all 12 Nordic zones, from 2021-11-01 (**available**) | free, no key |
87
+ | ENTSO-E Transparency Platform | Imbalance prices (A85), all 12 Nordic zones (**available, not yet live-verified**) | free token (`ENTSOE_API_KEY`) |
88
+
89
+ Out of scope: generic day-ahead price clients and mFRR bid submission — other projects
90
+ already cover those.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ uv sync
96
+ uv run pytest
97
+ uv run ruff check .
98
+ uv run ruff format --check .
99
+ uv run mypy
100
+ uv run pytest -m live # calls the real APIs; not run by default
101
+ ```
102
+
103
+ ## Licence
104
+
105
+ MIT — see [LICENSE](LICENSE). Data retrieved through this library remains subject to each
106
+ source's own terms of use.
107
+
108
+ This is an independent project. It is not affiliated with, endorsed by or supported by
109
+ Energinet, Fingrid, Svenska kraftnät, eSett, ENTSO-E or any other data provider.
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "nordic-balancing"
3
+ version = "0.1.0a1"
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"]
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 :: 2 - Pre-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
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=8.3",
45
+ "mypy>=1.11",
46
+ "ruff>=0.6",
47
+ ]
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.12.15,<0.13.0"]
51
+ build-backend = "uv_build"
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py312"
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "E",
60
+ "F",
61
+ "W",
62
+ "I",
63
+ "B",
64
+ "UP",
65
+ "SIM",
66
+ "RUF",
67
+ ]
68
+
69
+ [tool.mypy]
70
+ strict = true
71
+ files = [
72
+ "src",
73
+ "tests",
74
+ ]
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+ addopts = "-ra -m 'not live'"
79
+ markers = ["live: calls real data source APIs (run with: pytest -m live)"]
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "nordic-balancing"
3
+ version = "0.1.0a1"
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",
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 :: 2 - Pre-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
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8.3",
46
+ "mypy>=1.11",
47
+ "ruff>=0.6",
48
+ ]
49
+
50
+ [build-system]
51
+ requires = ["uv_build>=0.12.15,<0.13.0"]
52
+ build-backend = "uv_build"
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ target-version = "py312"
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
60
+
61
+ [tool.mypy]
62
+ strict = true
63
+ files = ["src", "tests"]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+ addopts = "-ra -m 'not live'"
68
+ markers = ["live: calls real data source APIs (run with: pytest -m live)"]
@@ -0,0 +1,54 @@
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.models import (
8
+ INTERVAL,
9
+ BiddingZone,
10
+ Direction,
11
+ ImbalancePrice,
12
+ ReserveCapacity,
13
+ ReserveProduct,
14
+ )
15
+ from nordic_balancing.reconcile import (
16
+ Divergence,
17
+ DivergenceKind,
18
+ ReconciliationReport,
19
+ reconcile_imbalance_prices,
20
+ )
21
+ from nordic_balancing.sources import (
22
+ EnergiDataServiceClient,
23
+ EntsoeClient,
24
+ ESettClient,
25
+ FingridClient,
26
+ SvKClient,
27
+ )
28
+
29
+ __version__ = version("nordic-balancing")
30
+
31
+ __all__ = [
32
+ "CHANGES",
33
+ "INTERVAL",
34
+ "BiddingZone",
35
+ "Direction",
36
+ "Divergence",
37
+ "DivergenceKind",
38
+ "ESettClient",
39
+ "EnergiDataServiceClient",
40
+ "EntsoeClient",
41
+ "FingridClient",
42
+ "ImbalancePrice",
43
+ "MarketChange",
44
+ "NordicBalancingError",
45
+ "RateLimitError",
46
+ "ReconciliationReport",
47
+ "ReserveCapacity",
48
+ "ReserveProduct",
49
+ "SourceError",
50
+ "SvKClient",
51
+ "__version__",
52
+ "changes_between",
53
+ "reconcile_imbalance_prices",
54
+ ]
@@ -0,0 +1,98 @@
1
+ """Shared bounded retries so sources handle throttling consistently."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from nordic_balancing.errors import RateLimitError, SourceError
9
+
10
+ _RETRY_STATUSES = frozenset({429, 503})
11
+ _DEFAULT_RETRY_AFTER = 10.0
12
+
13
+
14
+ def get_json(
15
+ http: httpx.Client,
16
+ base_url: str,
17
+ path: str,
18
+ params: Mapping[str, str] | list[tuple[str, str]],
19
+ *,
20
+ source: str,
21
+ max_retries: int,
22
+ sleep: Callable[[float], None],
23
+ ) -> Any:
24
+ response = _request(
25
+ http,
26
+ base_url,
27
+ path,
28
+ params,
29
+ source=source,
30
+ max_retries=max_retries,
31
+ sleep=sleep,
32
+ passthrough_statuses=frozenset(),
33
+ )
34
+ try:
35
+ return response.json()
36
+ except ValueError as exc:
37
+ raise SourceError(f"{source}: response for {path} is not JSON") from exc
38
+
39
+
40
+ def get_bytes(
41
+ http: httpx.Client,
42
+ base_url: str,
43
+ path: str,
44
+ params: Mapping[str, str] | list[tuple[str, str]],
45
+ *,
46
+ source: str,
47
+ max_retries: int,
48
+ sleep: Callable[[float], None],
49
+ passthrough_statuses: frozenset[int] = frozenset(),
50
+ ) -> tuple[bytes, str]:
51
+ response = _request(
52
+ http,
53
+ base_url,
54
+ path,
55
+ params,
56
+ source=source,
57
+ max_retries=max_retries,
58
+ sleep=sleep,
59
+ passthrough_statuses=passthrough_statuses,
60
+ )
61
+ return response.content, response.headers.get("content-type", "")
62
+
63
+
64
+ def _request(
65
+ http: httpx.Client,
66
+ base_url: str,
67
+ path: str,
68
+ params: Mapping[str, str] | list[tuple[str, str]],
69
+ *,
70
+ source: str,
71
+ max_retries: int,
72
+ sleep: Callable[[float], None],
73
+ passthrough_statuses: frozenset[int],
74
+ ) -> httpx.Response:
75
+ query_params = params if isinstance(params, Mapping) else tuple(params)
76
+ for attempt in range(max_retries + 1):
77
+ response = http.get(f"{base_url}{path}", params=query_params)
78
+ if response.status_code not in _RETRY_STATUSES:
79
+ break
80
+ retry_after = _retry_after(response)
81
+ if attempt == max_retries:
82
+ raise RateLimitError(source, retry_after)
83
+ sleep(retry_after if retry_after is not None else _DEFAULT_RETRY_AFTER)
84
+ if response.is_error and response.status_code not in passthrough_statuses:
85
+ raise SourceError(
86
+ f"{source}: HTTP {response.status_code} for {path}: {response.text[:200]}"
87
+ )
88
+ return response
89
+
90
+
91
+ def _retry_after(response: httpx.Response) -> float | None:
92
+ header = response.headers.get("Retry-After")
93
+ if header is None:
94
+ return None
95
+ try:
96
+ return max(float(header), 0.0)
97
+ except ValueError:
98
+ return None
@@ -0,0 +1,33 @@
1
+ """Shared record validators enforcing the data contracts in README.md."""
2
+
3
+ import math
4
+ from collections.abc import Hashable
5
+
6
+ from nordic_balancing.errors import SourceError
7
+
8
+
9
+ def finite_number(value: object, *, source: str, column: str) -> float | None:
10
+ """Return ``value`` as a float, ``None`` for null; reject the rest (C5).
11
+
12
+ A JSON bool, a non-number, a NaN/Infinity token and an overflowing value
13
+ are all rejected. The offending value is never echoed: Fingrid fields can
14
+ contain the API key.
15
+ """
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, bool) or not isinstance(value, int | float):
19
+ raise SourceError(f"{source}: {column} is not a number")
20
+ try:
21
+ number = float(value)
22
+ except OverflowError as exc:
23
+ raise SourceError(f"{source}: {column} is not finite") from exc
24
+ if not math.isfinite(number):
25
+ raise SourceError(f"{source}: {column} is not finite")
26
+ return number
27
+
28
+
29
+ def reject_duplicate[K: Hashable](seen: set[K], key: K, *, source: str, detail: str) -> None:
30
+ """Reject a second record for the same series key (C3)."""
31
+ if key in seen:
32
+ raise SourceError(f"{source}: duplicate {detail}")
33
+ seen.add(key)