bihgrid 0.1.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.
bihgrid/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """bihgrid — the Bosnia and Herzegovina transmission grid as a PyG dataset.
2
+
3
+ Real ≥220 kV grid topology (PyPSA-Eur / OSM) joined with ENTSO-E hourly
4
+ zone-level time series (2015→present), packaged as a
5
+ `torch_geometric.data.InMemoryDataset`.
6
+
7
+ from bihgrid import BiHGrid
8
+ ds = BiHGrid(root="data/bihgrid")
9
+ data = ds[0] # static graph + time-series tensors
10
+ ds.node_generation() # [T, N] heuristic per-bus generation
11
+
12
+ See https://github.com/FarukDziho/bih-power-data for sources, licenses and
13
+ the design decisions behind every heuristic (dataset/DESIGN.md).
14
+ """
15
+ from .dataset import BiHGrid
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["BiHGrid", "__version__"]
bihgrid/dataset.py ADDED
@@ -0,0 +1,229 @@
1
+ """BiHGrid: torch_geometric InMemoryDataset for the BiH transmission grid."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+ from torch_geometric.data import Data, InMemoryDataset
11
+
12
+ REPO = "FarukDziho/bih-power-data"
13
+ RAW_FILES = [
14
+ "nodes.csv",
15
+ "edges.csv",
16
+ "gen_bus_weights.csv",
17
+ "zone_series.parquet",
18
+ "dataset_summary.json",
19
+ ]
20
+
21
+ # static node features, in x column order
22
+ NODE_FEATURES = [
23
+ "voltage_kv",
24
+ "lon",
25
+ "lat",
26
+ "is_ba", # 1 = BA bus, 0 = border bus
27
+ "plant_coal_mw",
28
+ "plant_hydro_reservoir_mw",
29
+ "plant_hydro_pumped_storage_mw",
30
+ "plant_hydro_ror_mw",
31
+ "plant_wind_mw",
32
+ ]
33
+ # edge features, in edge_attr column order
34
+ EDGE_FEATURES = ["r", "x", "b", "s_nom", "length_km", "circuits",
35
+ "is_transformer", "cross_border"]
36
+
37
+ _PLANT_COLS = {
38
+ "plant_coal_mw": ["plant_fossil_brown_coal_lignite_mw", "plant_fossil_hard_coal_mw"],
39
+ "plant_hydro_reservoir_mw": ["plant_hydro_water_reservoir_mw"],
40
+ "plant_hydro_pumped_storage_mw": ["plant_hydro_pumped_storage_mw"],
41
+ "plant_hydro_ror_mw": ["plant_hydro_run-of-river_and_poundage_mw",
42
+ "plant_hydro_run_of_river_and_poundage_mw"],
43
+ "plant_wind_mw": ["plant_wind_onshore_mw"],
44
+ }
45
+
46
+
47
+ class BiHGrid(InMemoryDataset):
48
+ """The Bosnia and Herzegovina ≥220 kV transmission grid.
49
+
50
+ One static graph (51 buses, 67 per-circuit edges incl. transformers)
51
+ plus hourly ENTSO-E zone-level series (UTC) and a curated
52
+ generation→bus weight matrix.
53
+
54
+ Args:
55
+ root: cache directory.
56
+ revision: git revision of ``FarukDziho/bih-power-data`` to download
57
+ the dataset files from. A tag like ``"v0.1.0"`` first tries the
58
+ GitHub-release asset zip, then falls back to raw files at that
59
+ revision; ``"main"`` (default) uses the latest raw files.
60
+ transform, pre_transform: standard PyG hooks.
61
+
62
+ Node/edge feature orders are ``bihgrid.dataset.NODE_FEATURES`` and
63
+ ``bihgrid.dataset.EDGE_FEATURES``. The heuristics and their limits are
64
+ documented in the repo's ``dataset/DESIGN.md`` — read it before using
65
+ node-level generation as ground truth (it is capacity-weighted
66
+ zone data, not per-plant metering).
67
+ """
68
+
69
+ def __init__(self, root: str | Path, revision: str = "main",
70
+ transform=None, pre_transform=None, force_reload: bool = False):
71
+ self.revision = revision
72
+ super().__init__(str(root), transform, pre_transform,
73
+ force_reload=force_reload)
74
+ self.load(self.processed_paths[0])
75
+ with open(Path(self.processed_dir) / "meta.json") as f:
76
+ self._meta = json.load(f)
77
+
78
+ # ------------------------------------------------------------------ names
79
+ @property
80
+ def raw_file_names(self):
81
+ return RAW_FILES
82
+
83
+ @property
84
+ def processed_file_names(self):
85
+ return ["data.pt", "meta.json"]
86
+
87
+ # ------------------------------------------------------------------ download
88
+ def download(self):
89
+ import io
90
+ import zipfile
91
+ import requests
92
+
93
+ raw_dir = Path(self.raw_dir)
94
+ if self.revision.startswith("v"):
95
+ url = (f"https://github.com/{REPO}/releases/download/"
96
+ f"{self.revision}/bihgrid-dataset.zip")
97
+ r = requests.get(url, timeout=300)
98
+ if r.ok:
99
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
100
+ for name in RAW_FILES:
101
+ member = next((m for m in z.namelist()
102
+ if m.endswith("dataset/" + name)), None)
103
+ if member is None:
104
+ break
105
+ (raw_dir / name).write_bytes(z.read(member))
106
+ else:
107
+ return
108
+ base = (f"https://raw.githubusercontent.com/{REPO}/"
109
+ f"{self.revision}/dataset/")
110
+ for name in RAW_FILES:
111
+ r = requests.get(base + name, timeout=300)
112
+ r.raise_for_status()
113
+ (raw_dir / name).write_bytes(r.content)
114
+
115
+ # ------------------------------------------------------------------ process
116
+ def process(self):
117
+ raw = Path(self.raw_dir)
118
+ nodes = pd.read_csv(raw / "nodes.csv", dtype={"bus_id": str})
119
+ edges = pd.read_csv(raw / "edges.csv", dtype={"bus0": str, "bus1": str})
120
+ weights = pd.read_csv(raw / "gen_bus_weights.csv", dtype={"bus_id": str})
121
+ zone = pd.read_parquet(raw / "zone_series.parquet")
122
+ summary = json.loads((raw / "dataset_summary.json").read_text())
123
+
124
+ bus_ids = nodes["bus_id"].tolist()
125
+ idx = {b: i for i, b in enumerate(bus_ids)}
126
+
127
+ # ---- static node features
128
+ feats = pd.DataFrame(index=nodes.index)
129
+ feats["voltage_kv"] = nodes["voltage"].astype(float)
130
+ feats["lon"] = nodes["x"].astype(float)
131
+ feats["lat"] = nodes["y"].astype(float)
132
+ feats["is_ba"] = (nodes["role"] == "ba").astype(float)
133
+ for out_col, candidates in _PLANT_COLS.items():
134
+ cols = [c for c in candidates if c in nodes.columns]
135
+ feats[out_col] = nodes[cols].sum(axis=1) if cols else 0.0
136
+ x = torch.tensor(feats[NODE_FEATURES].to_numpy(), dtype=torch.float)
137
+ pos = torch.tensor(nodes[["x", "y"]].to_numpy(), dtype=torch.float)
138
+
139
+ # ---- edges (undirected -> both directions; parallel circuits kept)
140
+ e = edges.copy()
141
+ e["is_transformer"] = (e["kind"] == "transformer").astype(float)
142
+ e["cross_border"] = e["cross_border"].astype(bool).astype(float)
143
+ for c in ["r", "x", "b", "s_nom", "length_km", "circuits"]:
144
+ e[c] = pd.to_numeric(e.get(c), errors="coerce").fillna(0.0)
145
+ src = e["bus0"].map(idx).to_numpy()
146
+ dst = e["bus1"].map(idx).to_numpy()
147
+ attr = e[EDGE_FEATURES].to_numpy(dtype=np.float32)
148
+ edge_index = torch.tensor(np.concatenate([np.stack([src, dst]),
149
+ np.stack([dst, src])], axis=1),
150
+ dtype=torch.long)
151
+ edge_attr = torch.tensor(np.concatenate([attr, attr]), dtype=torch.float)
152
+
153
+ # ---- generation weight matrix [N, n_types]
154
+ types = sorted(weights["entsoe_type"].unique())
155
+ W = np.zeros((len(bus_ids), len(types)), dtype=np.float32)
156
+ for _, r in weights.iterrows():
157
+ W[idx[r["bus_id"]], types.index(r["entsoe_type"])] = r["weight"]
158
+
159
+ # ---- zone-level hourly series [T, F] (NaN kept as NaN)
160
+ zone = zone.sort_index()
161
+ zone_t = torch.tensor(zone.to_numpy(dtype=np.float32))
162
+ time_unix = torch.tensor(
163
+ zone.index.view("int64") // 10**9, dtype=torch.long)
164
+
165
+ data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, pos=pos,
166
+ gen_weights=torch.tensor(W),
167
+ zone_series=zone_t, time_unix=time_unix)
168
+ data.num_nodes = len(bus_ids)
169
+ if self.pre_transform is not None:
170
+ data = self.pre_transform(data)
171
+ self.save([data], self.processed_paths[0])
172
+
173
+ meta = {
174
+ "bus_ids": bus_ids,
175
+ "nearest_place": nodes.get("nearest_place",
176
+ pd.Series([None] * len(nodes))).tolist(),
177
+ "node_features": NODE_FEATURES,
178
+ "edge_features": EDGE_FEATURES,
179
+ "gen_weight_types": types,
180
+ "zone_columns": zone.columns.tolist(),
181
+ "revision": self.revision,
182
+ "summary": summary,
183
+ }
184
+ (Path(self.processed_dir) / "meta.json").write_text(json.dumps(meta))
185
+
186
+ # ------------------------------------------------------------------ helpers
187
+ @property
188
+ def meta(self) -> dict:
189
+ return self._meta
190
+
191
+ @property
192
+ def bus_ids(self) -> list[str]:
193
+ return self._meta["bus_ids"]
194
+
195
+ @property
196
+ def zone_columns(self) -> list[str]:
197
+ return self._meta["zone_columns"]
198
+
199
+ def zone_dataframe(self) -> pd.DataFrame:
200
+ """Zone-level hourly series as a pandas DataFrame (UTC index)."""
201
+ d = self[0]
202
+ index = pd.to_datetime(d.time_unix.numpy(), unit="s", utc=True)
203
+ return pd.DataFrame(d.zone_series.numpy(), index=index,
204
+ columns=self.zone_columns)
205
+
206
+ def node_generation(self, gen_type: str = "Fossil coal (combined)"
207
+ ) -> torch.Tensor:
208
+ """Heuristic per-bus generation series ``[T, N]`` for one type.
209
+
210
+ This is zone generation spread over buses by installed-capacity
211
+ share of *mapped* plants — a documented heuristic, not metering.
212
+ For ``"Fossil coal (combined)"`` the two ENTSO-E fossil columns are
213
+ summed first (their classification drifts across years).
214
+ """
215
+ d = self[0]
216
+ types = self._meta["gen_weight_types"]
217
+ if gen_type not in types:
218
+ raise ValueError(f"unknown type {gen_type!r}; choose from {types}")
219
+ w = d.gen_weights[:, types.index(gen_type)] # [N]
220
+ cols = self.zone_columns
221
+ if gen_type == "Fossil coal (combined)":
222
+ sel = [cols.index("gen_fossil_brown_coal_lignite_mw"),
223
+ cols.index("gen_fossil_hard_coal_mw")]
224
+ zone = d.zone_series[:, sel].nansum(dim=1) # [T]
225
+ else:
226
+ name = ("gen_" + gen_type.lower().replace(" ", "_")
227
+ .replace("/", "_").replace("-", "_") + "_mw")
228
+ zone = d.zone_series[:, cols.index(name)]
229
+ return zone.unsqueeze(1) * w.unsqueeze(0)
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: bihgrid
3
+ Version: 0.1.0
4
+ Summary: The Bosnia and Herzegovina power grid as a PyTorch Geometric dataset: real >=220kV topology + ENTSO-E hourly time series (2015-present)
5
+ Project-URL: Homepage, https://github.com/FarukDziho/bih-power-data
6
+ Project-URL: Repository, https://github.com/FarukDziho/bih-power-data
7
+ Project-URL: Documentation, https://github.com/FarukDziho/bih-power-data#readme
8
+ Author-email: Faruk Dziho <faruk.dziho@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: Bosnia and Herzegovina,ENTSO-E,dataset,energy,graph-neural-networks,power-grid,pytorch-geometric
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: numpy
19
+ Requires-Dist: pandas
20
+ Requires-Dist: pyarrow
21
+ Requires-Dist: requests
22
+ Requires-Dist: torch-geometric>=2.4
23
+ Requires-Dist: torch>=2.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # bih-power-data
27
+
28
+ Continuously refreshed dataset and monthly report for the **Bosnia and Herzegovina power system**
29
+ (BA control area, operated by [NOSBiH](https://www.nosbih.ba/en/)), collected from the
30
+ [ENTSO-E Transparency Platform](https://transparency.entsoe.eu/).
31
+
32
+ **Live report:** `report/index.html` (enable GitHub Pages on this repo to serve it).
33
+
34
+ ## What's collected
35
+
36
+ | dataset | description |
37
+ |---|---|
38
+ | `load_actual` | actual total load, hourly |
39
+ | `load_forecast` | day-ahead load forecast |
40
+ | `generation_per_type` | actual generation per production type (lignite, hydro ×3, wind, solar…) |
41
+ | `generation_forecast` | day-ahead generation forecast |
42
+ | `installed_capacity` | installed capacity per type (annual) |
43
+ | `flow_BA_XX` / `flow_XX_BA` | cross-border physical flows with HR / RS / ME, both directions |
44
+ | `sched_BA_XX` / `sched_XX_BA` | scheduled commercial exchanges with HR / RS / ME |
45
+ | `day_ahead_prices`, `hydro_reservoir_storage` | attempted; BA may not report these (status noted in `data/state.json`) |
46
+
47
+ Everything is stored as parquet in `data/`, full history from 2015 where available.
48
+ `data/state.json` tracks coverage and per-dataset status.
49
+
50
+ ## Setup
51
+
52
+ 1. Register (free) at [transparency.entsoe.eu](https://transparency.entsoe.eu/) and generate an
53
+ API security token in your account settings.
54
+ 2. First full collection (locally):
55
+
56
+ ```bash
57
+ pip install -r requirements.txt
58
+ ENTSOE_TOKEN=your-token python src/collect.py # full history on first run (takes a while)
59
+ python src/build_report.py # writes report/index.html
60
+ ```
61
+
62
+ 3. Push to GitHub, then add the token so the weekly refresh works:
63
+ **Settings → Secrets and variables → Actions → New repository secret** → name `ENTSOE_TOKEN`.
64
+
65
+ The included workflow (`.github/workflows/refresh.yml`) then re-collects incrementally every
66
+ Monday 03:17 UTC and commits any new data plus a rebuilt report. You can also trigger it manually
67
+ from the Actions tab (**Run workflow**).
68
+
69
+ ## Pipeline test without a token
70
+
71
+ ```bash
72
+ python src/collect.py --demo && python src/build_report.py
73
+ ```
74
+
75
+ generates synthetic data so the whole pipeline and report can be exercised end-to-end.
76
+
77
+ ## Graph-ready dataset
78
+
79
+ `src/join_graph.py` joins the ENTSO-E time series onto the ≥220 kV topology
80
+ (`topology/out/`) and writes a graph-ready dataset into `dataset/`: hourly
81
+ zone-level series (UTC), 51 buses with `nearest_place` labels and per-type
82
+ plant capacities (from the curated `topology/plants.csv`), 67 per-circuit
83
+ edges with electrical parameters, generation→bus weight vectors, and a
84
+ MultiGraph GraphML. All design decisions (zone-level vs node-level signals,
85
+ plant mapping coverage, cleaning rules) are documented in
86
+ [`dataset/DESIGN.md`](dataset/DESIGN.md). Runs offline from the repo checkout:
87
+
88
+ ```bash
89
+ python src/join_graph.py
90
+ ```
91
+
92
+ ## `bihgrid` — PyTorch Geometric package
93
+
94
+ The dataset ships as a PyG `InMemoryDataset` (auto-downloads from the GitHub
95
+ release or raw files, caches locally):
96
+
97
+ ```bash
98
+ pip install bihgrid
99
+ ```
100
+
101
+ ```python
102
+ from bihgrid import BiHGrid
103
+
104
+ ds = BiHGrid(root="data/bihgrid") # revision="v0.1.0" pins a release
105
+ data = ds[0]
106
+ data.x # [51, 9] voltage, coords, role, per-type plant MW
107
+ data.edge_index # [2, 134] 67 undirected per-circuit edges, both directions
108
+ data.edge_attr # [134, 8] r, x, b, s_nom, length_km, circuits, flags
109
+ data.zone_series # [T, 18] hourly zone signals, UTC (NaN = gap/cleaned)
110
+ ds.zone_dataframe() # same as pandas DataFrame
111
+ ds.node_generation("Fossil coal (combined)") # [T, 51] heuristic — see DESIGN.md
112
+ ```
113
+
114
+ Feature orders: `bihgrid.dataset.NODE_FEATURES` / `EDGE_FEATURES`. Read
115
+ [`dataset/DESIGN.md`](dataset/DESIGN.md) and [`DATASET_CARD.md`](DATASET_CARD.md)
116
+ before treating node-level signals as measurements — they are documented
117
+ heuristics over zone-level data. Tests: `python -m pytest tests/`.
118
+
119
+ ## Releasing
120
+
121
+ `git tag vX.Y.Z && git push origin vX.Y.Z` → tests, GitHub release with
122
+ dataset zip, PyPI publish, Zenodo DOI. One-time account setup in
123
+ [`PUBLISHING.md`](PUBLISHING.md).
124
+
125
+ ## Roadmap
126
+
127
+ - [x] Combine with grid topology (PyPSA-Eur / OpenStreetMap extract for BiH) into a
128
+ graph-ready dataset — candidate for publication. → `dataset/`, `src/join_graph.py`
129
+ - [x] PyG package `bihgrid` (InMemoryDataset, auto-download, PyPI). → `bihgrid/`
130
+ - [x] Zenodo deposit with versioned DOI + dataset card. → `.zenodo.json`,
131
+ `DATASET_CARD.md`, automated via `release.yml` (one-time setup in `PUBLISHING.md`)
132
+ - [ ] Data paper (Scientific Data / NeurIPS D&B).
133
+ - [ ] Companion collectors for EIA + ERCOT following the same layout.
134
+
135
+ ## License / attribution
136
+
137
+ Data © ENTSO-E Transparency Platform, reported by NOSBiH; reuse subject to ENTSO-E's
138
+ [terms](https://transparency.entsoe.eu/content/static_content/Static%20content/terms%20and%20conditions/terms%20and%20conditions.html).
139
+ Collection and report code: MIT.
@@ -0,0 +1,6 @@
1
+ bihgrid/__init__.py,sha256=ghc5IUy0dY8sNaOdWqlkAX3rQ_MnfyoEMtLaeItBSvE,680
2
+ bihgrid/dataset.py,sha256=nXpSQVl7aBd6ghLMXGTZfSquqEuEaGTg-9fpgJbxBzc,9598
3
+ bihgrid-0.1.0.dist-info/METADATA,sha256=iTN1oMSJyvpMXepU2JI11cdK_FGYGXMyST6HKa8Lz8Y,6039
4
+ bihgrid-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ bihgrid-0.1.0.dist-info/licenses/LICENSE,sha256=6ZM94bBr-3GCGqd8TveAgtqCiukteHFTWAb_0f0SjBQ,1068
6
+ bihgrid-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faruk Dziho
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.