fdia-graph 0.5.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.
- fdia_graph-0.5.0/LICENSE +51 -0
- fdia_graph-0.5.0/PKG-INFO +249 -0
- fdia_graph-0.5.0/README.md +210 -0
- fdia_graph-0.5.0/pyproject.toml +42 -0
- fdia_graph-0.5.0/setup.cfg +4 -0
- fdia_graph-0.5.0/src/fdia_graph/__init__.py +121 -0
- fdia_graph-0.5.0/src/fdia_graph/_core.py +502 -0
- fdia_graph-0.5.0/src/fdia_graph/dataset.py +370 -0
- fdia_graph-0.5.0/src/fdia_graph/download.py +111 -0
- fdia_graph-0.5.0/src/fdia_graph/generation.py +321 -0
- fdia_graph-0.5.0/src/fdia_graph/profiles.py +293 -0
- fdia_graph-0.5.0/src/fdia_graph/registry.py +121 -0
- fdia_graph-0.5.0/src/fdia_graph.egg-info/PKG-INFO +249 -0
- fdia_graph-0.5.0/src/fdia_graph.egg-info/SOURCES.txt +15 -0
- fdia_graph-0.5.0/src/fdia_graph.egg-info/dependency_links.txt +1 -0
- fdia_graph-0.5.0/src/fdia_graph.egg-info/requires.txt +26 -0
- fdia_graph-0.5.0/src/fdia_graph.egg-info/top_level.txt +1 -0
fdia_graph-0.5.0/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Creative Commons Attribution 4.0 International (CC BY 4.0)
|
|
2
|
+
|
|
3
|
+
The fdia-graph datasets (the HDF5 shards, operating-point pools, and derived
|
|
4
|
+
data distributed through this repository's releases) are licensed under the
|
|
5
|
+
Creative Commons Attribution 4.0 International License.
|
|
6
|
+
|
|
7
|
+
You are free to:
|
|
8
|
+
|
|
9
|
+
Share — copy and redistribute the material in any medium or format
|
|
10
|
+
Adapt — remix, transform, and build upon the material for any purpose,
|
|
11
|
+
even commercially.
|
|
12
|
+
|
|
13
|
+
Under the following terms:
|
|
14
|
+
|
|
15
|
+
Attribution — You must give appropriate credit, provide a link to the
|
|
16
|
+
license, and indicate if changes were made. You may do so in any reasonable
|
|
17
|
+
manner, but not in any way that suggests the licensor endorses you or your
|
|
18
|
+
use.
|
|
19
|
+
|
|
20
|
+
Full license text: https://creativecommons.org/licenses/by/4.0/legalcode
|
|
21
|
+
|
|
22
|
+
Disclaimer: this is synthetic data generated from public IEEE test cases
|
|
23
|
+
(pandapower case14/case118/case300) with simulated measurements and simulated
|
|
24
|
+
attack injections. It does not describe any real power system, real telemetry,
|
|
25
|
+
or real operating conditions, and it must not be used for operational
|
|
26
|
+
decisions.
|
|
27
|
+
|
|
28
|
+
The source code in this repository (src/, examples/) is licensed under the MIT
|
|
29
|
+
License:
|
|
30
|
+
|
|
31
|
+
MIT License
|
|
32
|
+
|
|
33
|
+
Copyright (c) 2026 Benjamin Myers
|
|
34
|
+
|
|
35
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
36
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
37
|
+
in the Software without restriction, including without limitation the rights
|
|
38
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
39
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
40
|
+
furnished to do so, subject to the following conditions:
|
|
41
|
+
|
|
42
|
+
The above copyright notice and this permission notice shall be included in all
|
|
43
|
+
copies or substantial portions of the Software.
|
|
44
|
+
|
|
45
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
46
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
47
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
48
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
49
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
50
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
51
|
+
SOFTWARE.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fdia-graph
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Load and generate ML-only dangerous FDIA localization datasets (realistic measurement graphs) for power grids, PyTorch-ready.
|
|
5
|
+
Author-email: Benjamin Myers <benjamin.myers.465@my.csun.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/myersben9/fdia-graph
|
|
8
|
+
Project-URL: Repository, https://github.com/myersben9/fdia-graph
|
|
9
|
+
Keywords: power-systems,false-data-injection,FDIA,state-estimation,graph-neural-network,smart-grid,dataset
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.21
|
|
18
|
+
Requires-Dist: h5py>=3.6
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Requires-Dist: tqdm>=4.60
|
|
21
|
+
Provides-Extra: torch
|
|
22
|
+
Requires-Dist: torch>=1.12; extra == "torch"
|
|
23
|
+
Provides-Extra: pyg
|
|
24
|
+
Requires-Dist: torch>=1.12; extra == "pyg"
|
|
25
|
+
Requires-Dist: torch_geometric>=2.2; extra == "pyg"
|
|
26
|
+
Provides-Extra: generate
|
|
27
|
+
Requires-Dist: pandapower>=2.10; extra == "generate"
|
|
28
|
+
Requires-Dist: pandas>=1.3; extra == "generate"
|
|
29
|
+
Provides-Extra: iso
|
|
30
|
+
Requires-Dist: gridstatus>=0.25; extra == "iso"
|
|
31
|
+
Requires-Dist: pandas>=1.3; extra == "iso"
|
|
32
|
+
Provides-Extra: all
|
|
33
|
+
Requires-Dist: torch>=1.12; extra == "all"
|
|
34
|
+
Requires-Dist: torch_geometric>=2.2; extra == "all"
|
|
35
|
+
Requires-Dist: pandapower>=2.10; extra == "all"
|
|
36
|
+
Requires-Dist: pandas>=1.3; extra == "all"
|
|
37
|
+
Requires-Dist: gridstatus>=0.25; extra == "all"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# fdia-graph
|
|
41
|
+
|
|
42
|
+
**Load and generate ML-only *dangerous* FDIA localization datasets for power grids — PyTorch-ready, zero data plumbing.**
|
|
43
|
+
|
|
44
|
+
`fdia-graph` gives power-systems / ML researchers one-line access to a benchmark of **false-data-injection attacks that evade every classical detector but are localizable only by a model** — on realistic sparse SCADA/PMU measurement graphs for IEEE-14 / 118 / 300. Install it, call `load(...)`, and train.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import fdia_graph as fg
|
|
48
|
+
|
|
49
|
+
ds = fg.load("ieee118", split="train") # auto-downloads + caches the newest release
|
|
50
|
+
loader = ds.loader(batch_size=64) # ready-to-train PyTorch DataLoader
|
|
51
|
+
for batch in loader:
|
|
52
|
+
batch["node_x"], batch["edge_x"], batch["edge_index"], batch["y"], batch["family"], ...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Why this dataset
|
|
58
|
+
|
|
59
|
+
Most FDIA benchmarks contain attacks a bad-data detector (BDD) catches, so "ML beats BDD" is unsurprising. This dataset is built around the opposite: **three stealthy families that provably evade classical detection** — and are genuinely dangerous — alongside three detectable families as a contrast set.
|
|
60
|
+
|
|
61
|
+
| Family | Type | Classical BDD |
|
|
62
|
+
|--------|------|---------------|
|
|
63
|
+
| `Aq` | stealthy load scaling: bounded per-bus rescale + AC re-solve — **our contribution** (cf. Boyaci `Ao`, Liu FDIA) | **evades (stealthy)** |
|
|
64
|
+
| `At` | temporal load surge, ramps up then back down (Haghshenas et al., IEEE ISGT 2023) | **evades (stealthy)** |
|
|
65
|
+
| `Al` | targeted masked-overload / load redistribution (Yuan, Li & Ren, IEEE T-SG 2011) | **evades (stealthy)** |
|
|
66
|
+
| `Ad` | random meter corruption | caught |
|
|
67
|
+
| `As` | meter scaling | caught |
|
|
68
|
+
| `Ar` | replay | mostly caught |
|
|
69
|
+
|
|
70
|
+
Each record is a **PING-style measurement graph** (branch flows as edge features, metered injections + |V| + sparse PMU angles as node features, with availability masks) — what a real EMS actually sees (redundancy ≈ 2–3), not the full-injection idealization.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install fdia-graph # loader (numpy + h5py)
|
|
76
|
+
pip install "fdia-graph[torch]" # + PyTorch Dataset/DataLoader
|
|
77
|
+
pip install "fdia-graph[pyg]" # + torch_geometric graph format
|
|
78
|
+
pip install "fdia-graph[generate]" # + pandapower, to generate custom datasets from load profiles
|
|
79
|
+
pip install "fdia-graph[iso]" # + gridstatus, to auto-download CAISO/ERCOT load profiles
|
|
80
|
+
pip install "fdia-graph[all]" # everything
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Everything is delivered from this package and its GitHub releases — datasets, operating-point pools, and (with `[generate]`) the whole simulation pipeline. There is no separate data drop to sync.
|
|
84
|
+
|
|
85
|
+
## Load
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
ds = fg.load("ieee300", split="train") # 60/20/20 chronological split
|
|
89
|
+
val = fg.load("ieee300", split="val")
|
|
90
|
+
tst = fg.load("ieee300", split="test")
|
|
91
|
+
|
|
92
|
+
# family subsets and the unseen-attack generalization protocol
|
|
93
|
+
stealthy = fg.load("ieee118", split="test", families=["Aq", "At", "Al"]) # "Ao"/"ramp"/"LRA" still work as aliases
|
|
94
|
+
heldout = fg.load("ieee118", split="train", heldout=True) # As/Ar excluded from train (Boyaci et al. 2022)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Any framework you like
|
|
98
|
+
|
|
99
|
+
The `.loader()` streams records for training; these pull the whole split at once for analysis:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ds = fg.load("ieee14", split="test")
|
|
103
|
+
arrays = ds.to_numpy() # dict of numpy arrays
|
|
104
|
+
tensors = ds.to_torch() # dict of torch tensors (float32 features, int64 labels)
|
|
105
|
+
tf_t = ds.to_tf() # dict of tf.Tensors (needs tensorflow)
|
|
106
|
+
df = ds.to_pandas() # flat pandas DataFrame, one row per record
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Dataset versioning
|
|
110
|
+
|
|
111
|
+
Datasets are GitHub **releases**, so your group version-controls them:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
fg.load("ieee118") # newest release (default — everyone stays current)
|
|
115
|
+
fg.load("ieee118", release="v0.3.0") # pin an exact version for a reproducible experiment
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Generate a custom dataset (research knobs)
|
|
119
|
+
|
|
120
|
+
Turn any research knob and load the result by name — no data plumbing:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
fg.generate("ieee118", name="high_intensity",
|
|
124
|
+
per_family=5000, # samples per attack family
|
|
125
|
+
families=["Aq", "At", "Al"],
|
|
126
|
+
attack_intensity=0.25, # per-bus load-shift magnitude (± fraction)
|
|
127
|
+
ramp_rate=0.003, ramp_len=80,
|
|
128
|
+
n_benign=30000,
|
|
129
|
+
redundancy={"pmu_frac": 0.3, "flow_frac": 0.95},
|
|
130
|
+
split=(0.7, 0.15, 0.15),
|
|
131
|
+
seed=7)
|
|
132
|
+
|
|
133
|
+
ds = fg.load("high_intensity", split="train") # your custom dataset, ready to train
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Generation ships with compact operating-point **pools** (a few MB/system), so you never need the raw simulation data. Benign records are emitted *exactly* from the stored operating state (0-error AC flows); only attacks re-solve a power flow.
|
|
137
|
+
|
|
138
|
+
### N-1 line outages (topology shift)
|
|
139
|
+
|
|
140
|
+
A switching event moves the operating manifold, so anything fitted on the intact network (a subspace prior, a learned estimator) is extrapolating afterwards. `outage=` builds a shard under a post-contingency topology: the line is taken out of service before Ybus, PTDF, the base operating point and every emitted measurement are derived, so the physics is genuinely different rather than masked.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
acc, rej = fg.line_outage_candidates(118, top_n=5) # highest base-case flow, connectivity-screened
|
|
144
|
+
# rej explains every contingency that was skipped (islanding, non-convergent, ...)
|
|
145
|
+
|
|
146
|
+
from fdia_graph._core import FdiaGenerator
|
|
147
|
+
g = FdiaGenerator(118, seed=123, outage=acc[0]["line"])
|
|
148
|
+
states, ok = g.resolve_states(pool_X) # re-solve the SAME load timestamps under this topology
|
|
149
|
+
|
|
150
|
+
fg.generate(118, name="ieee118_n1", states=states[ok], outage=acc[0]["line"])
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
One shard per topology: each file's `graph/` group describes its own network (`edge_status` carries the single zero), so the loader is unchanged and the static graph can never be wrong for a record. Feed each scenario a pool re-solved under that topology — a stored intact-network state emitted through a post-contingency Ybus would satisfy no power flow at all. Keep the load timestamps identical across scenarios, or topology and load level are confounded. Shard attrs record `topology`, `outage_line`, `outage_base_flow_mw` and the per-family `solve_yield` (attempts/accepted, so dropped hard cases stay visible).
|
|
154
|
+
|
|
155
|
+
## Own the whole pipeline: your own load profiles
|
|
156
|
+
|
|
157
|
+
The pools above are pre-built, but you can build your own from real grid load — swap in data from a different ISO or a different time period and regenerate everything. The front of the pipeline is two functions:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# 1. Get a load profile. Auto-download real system load at 5-minute resolution (the finest each ISO
|
|
161
|
+
# publishes), or bring your own series.
|
|
162
|
+
S = fg.fetch_profile("nyiso", "2024-01-01", "2024-06-30") # NYISO — no account, no extra deps
|
|
163
|
+
S = fg.fetch_profile("caiso", "2024-01-01", "2024-06-30") # CAISO — needs fdia-graph[iso]
|
|
164
|
+
S = fg.fetch_profile("ercot", "2024-01-01", "2024-06-30") # ERCOT — needs fdia-graph[iso]
|
|
165
|
+
S = fg.load_profile(my_load_array) # or a CSV / a numpy array of load values
|
|
166
|
+
|
|
167
|
+
# 2. Turn the profile into a pool of AC operating states, then generate attacks onto it.
|
|
168
|
+
states = fg.generate_states("ieee118", S) # [T, N, 4] via pandapower power flow
|
|
169
|
+
fg.generate(118, name="ieee118_nyiso_2024", states=states, per_family=5000)
|
|
170
|
+
ds = fg.load("ieee118_nyiso_2024", split="train")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`fetch_profile` returns a normalized per-timestep scaling vector; `generate_states` scales each bus's load by `clip(1 + k·S_t + noise)`, solves the power flow, and records the clean, bad-data-consistent state. Switching load source or time window is a one-line change, so the same grid can be re-generated under many demand regimes. Requires `fdia-graph[generate]` (`[iso]` too for CAISO/ERCOT).
|
|
174
|
+
|
|
175
|
+
## Schema
|
|
176
|
+
|
|
177
|
+
One HDF5 file per system (`ml_only_ieee{14,118,300}.h5`), with `N` = buses and `E` = branches (lines + transformers). The **static graph** is stored once; everything else is **per record** (`T` records total). A record is one realistic measurement snapshot — benign or attacked.
|
|
178
|
+
|
|
179
|
+
**Static graph** (read once, shared by every record):
|
|
180
|
+
|
|
181
|
+
| Field | Shape | Dtype | Meaning |
|
|
182
|
+
|-------|-------|-------|---------|
|
|
183
|
+
| `edge_index` | `[2, E]` | int64 | `[from_bus; to_bus]` for each branch (lines first, then transformers) |
|
|
184
|
+
| `edge_reactance` | `[E]` | float32 | per-branch reactance (p.u.) — handy for physics-informed models |
|
|
185
|
+
|
|
186
|
+
**Per-record measurement graph** (indexed `0 … T-1`; access one via `ds[i]`, or a whole split via `ds.to_numpy()`):
|
|
187
|
+
|
|
188
|
+
| Field | Shape | Dtype | Meaning |
|
|
189
|
+
|-------|-------|-------|---------|
|
|
190
|
+
| `node_x` | `[N, 4]` | float32 | node features `[ \|V\| (p.u.), P_inj (MW), Q_inj (MVAr), θ (deg) ]` |
|
|
191
|
+
| `node_m` | `[N, 4]` | float32 | node **availability mask** (1 = that meter exists at that bus, else 0; masked entries are zeroed) |
|
|
192
|
+
| `edge_x` | `[E, 2]` | float32 | branch-flow features `[ P_from (MW), Q_from (MVAr) ]` |
|
|
193
|
+
| `edge_m` | `[E, 2]` | float32 | edge availability mask |
|
|
194
|
+
| `y` | `[N]` | float32 | **localization target** — per-bus label, `1` = bus is attacked, `0` = clean |
|
|
195
|
+
| `temporal_delta` | `[N, 2]` | float32 | *(v0.3+)* current-minus-previous-scan injection `[ΔP_inj, ΔQ_inj]` — the temporal feature for replay/ramp |
|
|
196
|
+
| `family` | scalar | int | `0` benign · `1` Aq · `2` Ad · `3` As · `4` Ar · `5` At · `6` Al |
|
|
197
|
+
| `stealthy` | scalar | int | `1` if the attack evades classical bad-data detection (Ao/ramp/LRA), else `0` |
|
|
198
|
+
| `split` | scalar | int | `0` train · `1` val · `2` test (60/20/20 chronological, sequence-boundary safe) |
|
|
199
|
+
| `seq_id` | scalar | int | ramp-sequence id (`≥0` groups the scans of one multi-timestep ramp); `-1` otherwise |
|
|
200
|
+
| `timestep` | scalar | int | source operating-point index (the benign snapshot the record was built from) |
|
|
201
|
+
| `gap` | scalar | int | `1` if this is a physics non-convergence NA row (`≈0%` in the shipped data) |
|
|
202
|
+
|
|
203
|
+
Sparsity is real: `node_m`/`edge_m` encode a redundancy of ≈ 2–3 (a realistic EMS regime), so a model must consume the masks — not every bus is metered, and PMU angles (`θ`) are sparse.
|
|
204
|
+
|
|
205
|
+
## Project structure
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
fdia-graph/
|
|
209
|
+
├── pyproject.toml # package metadata, deps, optional extras ([torch], [pyg], [generate], [iso], [all])
|
|
210
|
+
├── README.md # this file
|
|
211
|
+
├── src/fdia_graph/
|
|
212
|
+
│ ├── __init__.py # public API: load(), generate(), fetch_profile/load_profile/generate_states ← start here
|
|
213
|
+
│ ├── registry.py # dataset version control: name → GitHub release + file; latest vs pinned; cache dir
|
|
214
|
+
│ ├── download.py # fetches release-asset .h5 shards (public, anonymous) → ~/.cache/fdia_graph
|
|
215
|
+
│ ├── dataset.py # FdiaGraph: torch Dataset over one .h5 (lazy slicing, split/family filters, exporters)
|
|
216
|
+
│ ├── profiles.py # front of the pipeline: ISO load download (CAISO/NYISO/ERCOT) → operating states
|
|
217
|
+
│ ├── generation.py # generate(): tunable-knob dataset creation → new .h5, registered by name
|
|
218
|
+
│ └── _core.py # generation engine: physics (Ybus/PTDF) + the 7 attack families
|
|
219
|
+
└── examples/
|
|
220
|
+
├── quickstart.py # smallest load → train loop
|
|
221
|
+
├── train_gnn.py # baseline GNN localizer
|
|
222
|
+
├── train_arma.py # ARMA + physics-biased attention localizer (the strong model)
|
|
223
|
+
├── train_tgnn.py # temporal-graph localizer
|
|
224
|
+
├── hpo_arma.py # Optuna hyperparameter search (Boyaci-style space)
|
|
225
|
+
└── reproduce_report.ipynb # end-to-end notebook: generate → figures → train/test
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Read order to understand the codebase: `__init__.py` (the two entry points) → `registry.py`/`download.py` (how a name becomes a local `.h5`) → `dataset.py` (how that `.h5` becomes tensors) → `generate.py`/`_core.py` (how new datasets are made). Every module is commented line-by-line.
|
|
229
|
+
|
|
230
|
+
## Evaluation protocol
|
|
231
|
+
|
|
232
|
+
60/20/20 **chronological** split cut on sequence boundaries (ramp sequences never straddle a split — a random shuffle would leak them). Equal count per attack family. **Report per-attack-type node-F1**, not accuracy — the stealthy families (`Ao`/`ramp`/`LRA`) are the hard ones and accuracy hides them.
|
|
233
|
+
|
|
234
|
+
## Citation
|
|
235
|
+
|
|
236
|
+
If you use this dataset, please cite the attack model and measurement-model sources:
|
|
237
|
+
|
|
238
|
+
- Yuan, Li & Ren, *Modeling load redistribution attacks in power systems*, IEEE Trans. Smart Grid 2(2), 2011. *(LRA attack)*
|
|
239
|
+
- Haghshenas, Hasnat & Naeini, *A Temporal Graph Neural Network for Cyber Attack Detection and Localization in Smart Grids*, IEEE ISGT 2023. *(ramp attack)*
|
|
240
|
+
- Zaman & Lin, *PING: Physics-Informed GNNs to Generalize FDIA Localization*, NAPS 2025. *(measurement model)*
|
|
241
|
+
- Boyaci et al., *Joint Detection and Localization of Stealth FDIA*, IEEE Trans. Smart Grid, 2022. *(protocol)*
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
The dataset (HDF5 shards and operating-point pools distributed through the GitHub releases) is
|
|
246
|
+
licensed under Creative Commons Attribution 4.0 International (CC BY 4.0). The source code in `src/`
|
|
247
|
+
and `examples/` is licensed under the MIT License. See the `LICENSE` file. The data is synthetic,
|
|
248
|
+
generated from public IEEE test cases with simulated measurements and simulated attacks, and must
|
|
249
|
+
not be used for operational decisions.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# fdia-graph
|
|
2
|
+
|
|
3
|
+
**Load and generate ML-only *dangerous* FDIA localization datasets for power grids — PyTorch-ready, zero data plumbing.**
|
|
4
|
+
|
|
5
|
+
`fdia-graph` gives power-systems / ML researchers one-line access to a benchmark of **false-data-injection attacks that evade every classical detector but are localizable only by a model** — on realistic sparse SCADA/PMU measurement graphs for IEEE-14 / 118 / 300. Install it, call `load(...)`, and train.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import fdia_graph as fg
|
|
9
|
+
|
|
10
|
+
ds = fg.load("ieee118", split="train") # auto-downloads + caches the newest release
|
|
11
|
+
loader = ds.loader(batch_size=64) # ready-to-train PyTorch DataLoader
|
|
12
|
+
for batch in loader:
|
|
13
|
+
batch["node_x"], batch["edge_x"], batch["edge_index"], batch["y"], batch["family"], ...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why this dataset
|
|
19
|
+
|
|
20
|
+
Most FDIA benchmarks contain attacks a bad-data detector (BDD) catches, so "ML beats BDD" is unsurprising. This dataset is built around the opposite: **three stealthy families that provably evade classical detection** — and are genuinely dangerous — alongside three detectable families as a contrast set.
|
|
21
|
+
|
|
22
|
+
| Family | Type | Classical BDD |
|
|
23
|
+
|--------|------|---------------|
|
|
24
|
+
| `Aq` | stealthy load scaling: bounded per-bus rescale + AC re-solve — **our contribution** (cf. Boyaci `Ao`, Liu FDIA) | **evades (stealthy)** |
|
|
25
|
+
| `At` | temporal load surge, ramps up then back down (Haghshenas et al., IEEE ISGT 2023) | **evades (stealthy)** |
|
|
26
|
+
| `Al` | targeted masked-overload / load redistribution (Yuan, Li & Ren, IEEE T-SG 2011) | **evades (stealthy)** |
|
|
27
|
+
| `Ad` | random meter corruption | caught |
|
|
28
|
+
| `As` | meter scaling | caught |
|
|
29
|
+
| `Ar` | replay | mostly caught |
|
|
30
|
+
|
|
31
|
+
Each record is a **PING-style measurement graph** (branch flows as edge features, metered injections + |V| + sparse PMU angles as node features, with availability masks) — what a real EMS actually sees (redundancy ≈ 2–3), not the full-injection idealization.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install fdia-graph # loader (numpy + h5py)
|
|
37
|
+
pip install "fdia-graph[torch]" # + PyTorch Dataset/DataLoader
|
|
38
|
+
pip install "fdia-graph[pyg]" # + torch_geometric graph format
|
|
39
|
+
pip install "fdia-graph[generate]" # + pandapower, to generate custom datasets from load profiles
|
|
40
|
+
pip install "fdia-graph[iso]" # + gridstatus, to auto-download CAISO/ERCOT load profiles
|
|
41
|
+
pip install "fdia-graph[all]" # everything
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Everything is delivered from this package and its GitHub releases — datasets, operating-point pools, and (with `[generate]`) the whole simulation pipeline. There is no separate data drop to sync.
|
|
45
|
+
|
|
46
|
+
## Load
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
ds = fg.load("ieee300", split="train") # 60/20/20 chronological split
|
|
50
|
+
val = fg.load("ieee300", split="val")
|
|
51
|
+
tst = fg.load("ieee300", split="test")
|
|
52
|
+
|
|
53
|
+
# family subsets and the unseen-attack generalization protocol
|
|
54
|
+
stealthy = fg.load("ieee118", split="test", families=["Aq", "At", "Al"]) # "Ao"/"ramp"/"LRA" still work as aliases
|
|
55
|
+
heldout = fg.load("ieee118", split="train", heldout=True) # As/Ar excluded from train (Boyaci et al. 2022)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Any framework you like
|
|
59
|
+
|
|
60
|
+
The `.loader()` streams records for training; these pull the whole split at once for analysis:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
ds = fg.load("ieee14", split="test")
|
|
64
|
+
arrays = ds.to_numpy() # dict of numpy arrays
|
|
65
|
+
tensors = ds.to_torch() # dict of torch tensors (float32 features, int64 labels)
|
|
66
|
+
tf_t = ds.to_tf() # dict of tf.Tensors (needs tensorflow)
|
|
67
|
+
df = ds.to_pandas() # flat pandas DataFrame, one row per record
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Dataset versioning
|
|
71
|
+
|
|
72
|
+
Datasets are GitHub **releases**, so your group version-controls them:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
fg.load("ieee118") # newest release (default — everyone stays current)
|
|
76
|
+
fg.load("ieee118", release="v0.3.0") # pin an exact version for a reproducible experiment
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Generate a custom dataset (research knobs)
|
|
80
|
+
|
|
81
|
+
Turn any research knob and load the result by name — no data plumbing:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
fg.generate("ieee118", name="high_intensity",
|
|
85
|
+
per_family=5000, # samples per attack family
|
|
86
|
+
families=["Aq", "At", "Al"],
|
|
87
|
+
attack_intensity=0.25, # per-bus load-shift magnitude (± fraction)
|
|
88
|
+
ramp_rate=0.003, ramp_len=80,
|
|
89
|
+
n_benign=30000,
|
|
90
|
+
redundancy={"pmu_frac": 0.3, "flow_frac": 0.95},
|
|
91
|
+
split=(0.7, 0.15, 0.15),
|
|
92
|
+
seed=7)
|
|
93
|
+
|
|
94
|
+
ds = fg.load("high_intensity", split="train") # your custom dataset, ready to train
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Generation ships with compact operating-point **pools** (a few MB/system), so you never need the raw simulation data. Benign records are emitted *exactly* from the stored operating state (0-error AC flows); only attacks re-solve a power flow.
|
|
98
|
+
|
|
99
|
+
### N-1 line outages (topology shift)
|
|
100
|
+
|
|
101
|
+
A switching event moves the operating manifold, so anything fitted on the intact network (a subspace prior, a learned estimator) is extrapolating afterwards. `outage=` builds a shard under a post-contingency topology: the line is taken out of service before Ybus, PTDF, the base operating point and every emitted measurement are derived, so the physics is genuinely different rather than masked.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
acc, rej = fg.line_outage_candidates(118, top_n=5) # highest base-case flow, connectivity-screened
|
|
105
|
+
# rej explains every contingency that was skipped (islanding, non-convergent, ...)
|
|
106
|
+
|
|
107
|
+
from fdia_graph._core import FdiaGenerator
|
|
108
|
+
g = FdiaGenerator(118, seed=123, outage=acc[0]["line"])
|
|
109
|
+
states, ok = g.resolve_states(pool_X) # re-solve the SAME load timestamps under this topology
|
|
110
|
+
|
|
111
|
+
fg.generate(118, name="ieee118_n1", states=states[ok], outage=acc[0]["line"])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
One shard per topology: each file's `graph/` group describes its own network (`edge_status` carries the single zero), so the loader is unchanged and the static graph can never be wrong for a record. Feed each scenario a pool re-solved under that topology — a stored intact-network state emitted through a post-contingency Ybus would satisfy no power flow at all. Keep the load timestamps identical across scenarios, or topology and load level are confounded. Shard attrs record `topology`, `outage_line`, `outage_base_flow_mw` and the per-family `solve_yield` (attempts/accepted, so dropped hard cases stay visible).
|
|
115
|
+
|
|
116
|
+
## Own the whole pipeline: your own load profiles
|
|
117
|
+
|
|
118
|
+
The pools above are pre-built, but you can build your own from real grid load — swap in data from a different ISO or a different time period and regenerate everything. The front of the pipeline is two functions:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# 1. Get a load profile. Auto-download real system load at 5-minute resolution (the finest each ISO
|
|
122
|
+
# publishes), or bring your own series.
|
|
123
|
+
S = fg.fetch_profile("nyiso", "2024-01-01", "2024-06-30") # NYISO — no account, no extra deps
|
|
124
|
+
S = fg.fetch_profile("caiso", "2024-01-01", "2024-06-30") # CAISO — needs fdia-graph[iso]
|
|
125
|
+
S = fg.fetch_profile("ercot", "2024-01-01", "2024-06-30") # ERCOT — needs fdia-graph[iso]
|
|
126
|
+
S = fg.load_profile(my_load_array) # or a CSV / a numpy array of load values
|
|
127
|
+
|
|
128
|
+
# 2. Turn the profile into a pool of AC operating states, then generate attacks onto it.
|
|
129
|
+
states = fg.generate_states("ieee118", S) # [T, N, 4] via pandapower power flow
|
|
130
|
+
fg.generate(118, name="ieee118_nyiso_2024", states=states, per_family=5000)
|
|
131
|
+
ds = fg.load("ieee118_nyiso_2024", split="train")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`fetch_profile` returns a normalized per-timestep scaling vector; `generate_states` scales each bus's load by `clip(1 + k·S_t + noise)`, solves the power flow, and records the clean, bad-data-consistent state. Switching load source or time window is a one-line change, so the same grid can be re-generated under many demand regimes. Requires `fdia-graph[generate]` (`[iso]` too for CAISO/ERCOT).
|
|
135
|
+
|
|
136
|
+
## Schema
|
|
137
|
+
|
|
138
|
+
One HDF5 file per system (`ml_only_ieee{14,118,300}.h5`), with `N` = buses and `E` = branches (lines + transformers). The **static graph** is stored once; everything else is **per record** (`T` records total). A record is one realistic measurement snapshot — benign or attacked.
|
|
139
|
+
|
|
140
|
+
**Static graph** (read once, shared by every record):
|
|
141
|
+
|
|
142
|
+
| Field | Shape | Dtype | Meaning |
|
|
143
|
+
|-------|-------|-------|---------|
|
|
144
|
+
| `edge_index` | `[2, E]` | int64 | `[from_bus; to_bus]` for each branch (lines first, then transformers) |
|
|
145
|
+
| `edge_reactance` | `[E]` | float32 | per-branch reactance (p.u.) — handy for physics-informed models |
|
|
146
|
+
|
|
147
|
+
**Per-record measurement graph** (indexed `0 … T-1`; access one via `ds[i]`, or a whole split via `ds.to_numpy()`):
|
|
148
|
+
|
|
149
|
+
| Field | Shape | Dtype | Meaning |
|
|
150
|
+
|-------|-------|-------|---------|
|
|
151
|
+
| `node_x` | `[N, 4]` | float32 | node features `[ \|V\| (p.u.), P_inj (MW), Q_inj (MVAr), θ (deg) ]` |
|
|
152
|
+
| `node_m` | `[N, 4]` | float32 | node **availability mask** (1 = that meter exists at that bus, else 0; masked entries are zeroed) |
|
|
153
|
+
| `edge_x` | `[E, 2]` | float32 | branch-flow features `[ P_from (MW), Q_from (MVAr) ]` |
|
|
154
|
+
| `edge_m` | `[E, 2]` | float32 | edge availability mask |
|
|
155
|
+
| `y` | `[N]` | float32 | **localization target** — per-bus label, `1` = bus is attacked, `0` = clean |
|
|
156
|
+
| `temporal_delta` | `[N, 2]` | float32 | *(v0.3+)* current-minus-previous-scan injection `[ΔP_inj, ΔQ_inj]` — the temporal feature for replay/ramp |
|
|
157
|
+
| `family` | scalar | int | `0` benign · `1` Aq · `2` Ad · `3` As · `4` Ar · `5` At · `6` Al |
|
|
158
|
+
| `stealthy` | scalar | int | `1` if the attack evades classical bad-data detection (Ao/ramp/LRA), else `0` |
|
|
159
|
+
| `split` | scalar | int | `0` train · `1` val · `2` test (60/20/20 chronological, sequence-boundary safe) |
|
|
160
|
+
| `seq_id` | scalar | int | ramp-sequence id (`≥0` groups the scans of one multi-timestep ramp); `-1` otherwise |
|
|
161
|
+
| `timestep` | scalar | int | source operating-point index (the benign snapshot the record was built from) |
|
|
162
|
+
| `gap` | scalar | int | `1` if this is a physics non-convergence NA row (`≈0%` in the shipped data) |
|
|
163
|
+
|
|
164
|
+
Sparsity is real: `node_m`/`edge_m` encode a redundancy of ≈ 2–3 (a realistic EMS regime), so a model must consume the masks — not every bus is metered, and PMU angles (`θ`) are sparse.
|
|
165
|
+
|
|
166
|
+
## Project structure
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
fdia-graph/
|
|
170
|
+
├── pyproject.toml # package metadata, deps, optional extras ([torch], [pyg], [generate], [iso], [all])
|
|
171
|
+
├── README.md # this file
|
|
172
|
+
├── src/fdia_graph/
|
|
173
|
+
│ ├── __init__.py # public API: load(), generate(), fetch_profile/load_profile/generate_states ← start here
|
|
174
|
+
│ ├── registry.py # dataset version control: name → GitHub release + file; latest vs pinned; cache dir
|
|
175
|
+
│ ├── download.py # fetches release-asset .h5 shards (public, anonymous) → ~/.cache/fdia_graph
|
|
176
|
+
│ ├── dataset.py # FdiaGraph: torch Dataset over one .h5 (lazy slicing, split/family filters, exporters)
|
|
177
|
+
│ ├── profiles.py # front of the pipeline: ISO load download (CAISO/NYISO/ERCOT) → operating states
|
|
178
|
+
│ ├── generation.py # generate(): tunable-knob dataset creation → new .h5, registered by name
|
|
179
|
+
│ └── _core.py # generation engine: physics (Ybus/PTDF) + the 7 attack families
|
|
180
|
+
└── examples/
|
|
181
|
+
├── quickstart.py # smallest load → train loop
|
|
182
|
+
├── train_gnn.py # baseline GNN localizer
|
|
183
|
+
├── train_arma.py # ARMA + physics-biased attention localizer (the strong model)
|
|
184
|
+
├── train_tgnn.py # temporal-graph localizer
|
|
185
|
+
├── hpo_arma.py # Optuna hyperparameter search (Boyaci-style space)
|
|
186
|
+
└── reproduce_report.ipynb # end-to-end notebook: generate → figures → train/test
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Read order to understand the codebase: `__init__.py` (the two entry points) → `registry.py`/`download.py` (how a name becomes a local `.h5`) → `dataset.py` (how that `.h5` becomes tensors) → `generate.py`/`_core.py` (how new datasets are made). Every module is commented line-by-line.
|
|
190
|
+
|
|
191
|
+
## Evaluation protocol
|
|
192
|
+
|
|
193
|
+
60/20/20 **chronological** split cut on sequence boundaries (ramp sequences never straddle a split — a random shuffle would leak them). Equal count per attack family. **Report per-attack-type node-F1**, not accuracy — the stealthy families (`Ao`/`ramp`/`LRA`) are the hard ones and accuracy hides them.
|
|
194
|
+
|
|
195
|
+
## Citation
|
|
196
|
+
|
|
197
|
+
If you use this dataset, please cite the attack model and measurement-model sources:
|
|
198
|
+
|
|
199
|
+
- Yuan, Li & Ren, *Modeling load redistribution attacks in power systems*, IEEE Trans. Smart Grid 2(2), 2011. *(LRA attack)*
|
|
200
|
+
- Haghshenas, Hasnat & Naeini, *A Temporal Graph Neural Network for Cyber Attack Detection and Localization in Smart Grids*, IEEE ISGT 2023. *(ramp attack)*
|
|
201
|
+
- Zaman & Lin, *PING: Physics-Informed GNNs to Generalize FDIA Localization*, NAPS 2025. *(measurement model)*
|
|
202
|
+
- Boyaci et al., *Joint Detection and Localization of Stealth FDIA*, IEEE Trans. Smart Grid, 2022. *(protocol)*
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
The dataset (HDF5 shards and operating-point pools distributed through the GitHub releases) is
|
|
207
|
+
licensed under Creative Commons Attribution 4.0 International (CC BY 4.0). The source code in `src/`
|
|
208
|
+
and `examples/` is licensed under the MIT License. See the `LICENSE` file. The data is synthetic,
|
|
209
|
+
generated from public IEEE test cases with simulated measurements and simulated attacks, and must
|
|
210
|
+
not be used for operational decisions.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fdia-graph"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Load and generate ML-only dangerous FDIA localization datasets (realistic measurement graphs) for power grids, PyTorch-ready."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Benjamin Myers", email = "benjamin.myers.465@my.csun.edu" }]
|
|
13
|
+
keywords = ["power-systems", "false-data-injection", "FDIA", "state-estimation", "graph-neural-network", "smart-grid", "dataset"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"numpy>=1.21",
|
|
22
|
+
"h5py>=3.6",
|
|
23
|
+
"requests>=2.25",
|
|
24
|
+
"tqdm>=4.60",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
torch = ["torch>=1.12"]
|
|
29
|
+
pyg = ["torch>=1.12", "torch_geometric>=2.2"]
|
|
30
|
+
generate = ["pandapower>=2.10", "pandas>=1.3"] # dataset generation + load-profile ingestion (needs a power-flow engine)
|
|
31
|
+
iso = ["gridstatus>=0.25", "pandas>=1.3"] # auto-download CAISO/ERCOT load profiles (NYISO works without it)
|
|
32
|
+
all = ["torch>=1.12", "torch_geometric>=2.2", "pandapower>=2.10", "pandas>=1.3", "gridstatus>=0.25"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/myersben9/fdia-graph"
|
|
36
|
+
Repository = "https://github.com/myersben9/fdia-graph"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
fdia_graph = ["profiles/*.npz"]
|