fdia-graph 0.5.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.
- fdia_graph/__init__.py +121 -0
- fdia_graph/_core.py +502 -0
- fdia_graph/dataset.py +370 -0
- fdia_graph/download.py +111 -0
- fdia_graph/generation.py +321 -0
- fdia_graph/profiles.py +293 -0
- fdia_graph/registry.py +121 -0
- fdia_graph-0.5.0.dist-info/METADATA +249 -0
- fdia_graph-0.5.0.dist-info/RECORD +12 -0
- fdia_graph-0.5.0.dist-info/WHEEL +5 -0
- fdia_graph-0.5.0.dist-info/licenses/LICENSE +51 -0
- fdia_graph-0.5.0.dist-info/top_level.txt +1 -0
fdia_graph/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""fdia-graph — load & generate ML-only dangerous FDIA localization datasets (realistic measurement graphs).
|
|
2
|
+
|
|
3
|
+
Quickstart
|
|
4
|
+
----------
|
|
5
|
+
import fdia_graph as fg
|
|
6
|
+
ds = fg.load("ieee118", split="train") # auto-downloads + caches the latest shard
|
|
7
|
+
loader = ds.loader(batch_size=64) # ready-to-train PyTorch DataLoader
|
|
8
|
+
for batch in loader:
|
|
9
|
+
batch["node_x"], batch["edge_x"], batch["y"], batch["family"], ...
|
|
10
|
+
|
|
11
|
+
# custom dataset with research knobs, then load it by name:
|
|
12
|
+
fg.generate("ieee118", name="my_run", per_family=5000, attack_intensity=0.20, ramp_rate=0.003)
|
|
13
|
+
ds = fg.load("my_run", split="train")
|
|
14
|
+
"""
|
|
15
|
+
# --- Re-exports: pull the workhorse names up to the package top level so users
|
|
16
|
+
# --- can write `fg.FdiaGraph` / `fg.load(...)` instead of reaching into submodules.
|
|
17
|
+
# FdiaGraph : the torch Dataset wrapper over one .h5 shard (see dataset.py).
|
|
18
|
+
# FAMILIES : the full ordered tuple of attack-family names/ids the datasets use.
|
|
19
|
+
# STEALTHY_FAMILIES: the subset that evades bad-data detection (the hard cases, e.g. Aq).
|
|
20
|
+
from .dataset import FdiaGraph, FAMILIES, STEALTHY_FAMILIES
|
|
21
|
+
# registry.py is the dataset "version control":
|
|
22
|
+
# list_datasets : enumerate the known built-in + locally-registered datasets.
|
|
23
|
+
# register_local : record a locally-generated dataset under a name so load() can find it.
|
|
24
|
+
# resolve : turn (name, release) into a concrete download spec (release tag + filename).
|
|
25
|
+
from .registry import list_datasets, register_local, resolve
|
|
26
|
+
# download.py handles the network/cache side:
|
|
27
|
+
# ensure_local : given a resolved spec, return a local .h5 path, fetching+caching if absent.
|
|
28
|
+
from .download import ensure_local
|
|
29
|
+
|
|
30
|
+
# Package version string, surfaced as fdia_graph.__version__ (standard convention).
|
|
31
|
+
__version__ = "0.5.0"
|
|
32
|
+
# The public surface: what `from fdia_graph import *` exposes and what we advertise as stable API.
|
|
33
|
+
# Note register_local/resolve/ensure_local are intentionally NOT here — they're internal plumbing.
|
|
34
|
+
__all__ = ["load", "generate", "load_profile", "fetch_profile", "generate_states",
|
|
35
|
+
"line_outage_candidates", "list_datasets", "FdiaGraph", "FAMILIES", "STEALTHY_FAMILIES"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def line_outage_candidates(system, top_n=5):
|
|
39
|
+
"""Rank single-line N-1 contingencies by base-case flow, screening out any that island the grid.
|
|
40
|
+
|
|
41
|
+
Returns (accepted, rejected) lists of dicts. Use the accepted line indices as generate(..., outage=idx)
|
|
42
|
+
to build one shard per post-contingency topology.
|
|
43
|
+
"""
|
|
44
|
+
# Lazy import: pulls in pandapower, which most SDK users (loaders) do not have installed.
|
|
45
|
+
from ._core import line_outage_candidates as _cands
|
|
46
|
+
return _cands(system, top_n=top_n)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_profile(iso, start, end, out=None, resample_min=None):
|
|
50
|
+
"""Auto-download an ISO system-load series and return a normalized scaling vector S [T].
|
|
51
|
+
|
|
52
|
+
iso is "caiso"/"nyiso"/"ercot"; start/end are 'YYYY-MM-DD'. NYISO needs no account or extra deps;
|
|
53
|
+
CAISO/ERCOT use the gridstatus package (pip install 'fdia-graph[iso]'). resample_min (e.g. 1)
|
|
54
|
+
time-interpolates the load to that minute cadence (upsample the 5-min feed to 1-min). See
|
|
55
|
+
fdia_graph.profiles. Feed the result to generate_states(). Requires the generation extra.
|
|
56
|
+
"""
|
|
57
|
+
from .profiles import fetch_profile as _fetch_profile
|
|
58
|
+
return _fetch_profile(iso, start, end, out=out, resample_min=resample_min)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_profile(source, path=None, column=None):
|
|
62
|
+
"""Ingest a load time series into a normalized scaling vector S [T] (see fdia_graph.profiles).
|
|
63
|
+
|
|
64
|
+
Pluggable front of the pipeline: `source` is "caiso"/"nyiso" (+ a `path` to the ISO CSV directory),
|
|
65
|
+
a generic CSV path (+ `column`), or an array of raw load values. Swap sources/time periods freely.
|
|
66
|
+
Requires the generation extra: pip install 'fdia-graph[generate]'.
|
|
67
|
+
"""
|
|
68
|
+
from .profiles import load_profile as _load_profile
|
|
69
|
+
return _load_profile(source, path=path, column=column)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generate_states(system, profile, **knobs):
|
|
73
|
+
"""Turn a load profile into a pool of AC operating states [T,N,4] to inject attacks onto.
|
|
74
|
+
|
|
75
|
+
Pass the result straight to generate(system, name, states=...). Knobs: k, sigma, clip, n, seed
|
|
76
|
+
(see fdia_graph.profiles.generate_states). Requires the generation extra.
|
|
77
|
+
"""
|
|
78
|
+
from .profiles import generate_states as _generate_states
|
|
79
|
+
return _generate_states(system, profile, **knobs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load(name, split=None, families=None, include_gaps=False, heldout=False, format="torch", release=None,
|
|
83
|
+
units="physical"):
|
|
84
|
+
"""Load a dataset by name (built-in shard auto-downloads; local generated ones load from disk).
|
|
85
|
+
|
|
86
|
+
name : "ieee14"|"ieee118"|"ieee300", or a locally-generated dataset name.
|
|
87
|
+
split : None (all) | "train" | "val" | "test" (chronological 60/20/20).
|
|
88
|
+
families : optional subset, e.g. ["Aq","At","Al"] or [1,5,6].
|
|
89
|
+
include_gaps: keep physics non-convergence NA rows (default False).
|
|
90
|
+
heldout : unseen-attack protocol — exclude As/Ar from train/val (Boyaci et al. 2022).
|
|
91
|
+
format : "torch" (dict batches) | "pyg" (torch_geometric Data).
|
|
92
|
+
release : dataset VERSION. None -> newest published release (default, always-current for the group);
|
|
93
|
+
an explicit tag e.g. "v0.2.0" -> that exact version, for reproducible experiments.
|
|
94
|
+
units : "physical" -> [V p.u., P_inj MW, Q_inj MVAr, theta deg] (as stored; human-readable for plots);
|
|
95
|
+
"pu" -> everything per-unit on baseMVA with theta in radians (ML/physics). Same shard either way.
|
|
96
|
+
"""
|
|
97
|
+
# Two-step resolution: resolve() maps (name, release) to a download spec (which GitHub
|
|
98
|
+
# release + which asset), then ensure_local() downloads+caches it if needed and hands
|
|
99
|
+
# back a concrete on-disk .h5 path. Local generated datasets short-circuit to their file.
|
|
100
|
+
path = ensure_local(resolve(name, release=release))
|
|
101
|
+
# Wrap that shard in an FdiaGraph. All the row-selection knobs (split/families/gaps/heldout)
|
|
102
|
+
# and the export format are applied lazily by the Dataset, not here — this is a thin factory.
|
|
103
|
+
return FdiaGraph(path, split=split, families=families, include_gaps=include_gaps, heldout=heldout,
|
|
104
|
+
format=format, units=units)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate(system, name, **knobs):
|
|
108
|
+
"""Generate a custom dataset with research knobs and register it as `name` (loadable via load(name)).
|
|
109
|
+
|
|
110
|
+
Knobs (all optional): per_family, families, attack_intensity, ramp_rate, ramp_len, n_benign,
|
|
111
|
+
redundancy, split, seed, out. See fdia_graph.generation module for the full documented signature.
|
|
112
|
+
Requires the generation extra: pip install 'fdia-graph[generate]'.
|
|
113
|
+
"""
|
|
114
|
+
# Lazy import: the generator pulls in heavy deps (pandapower, the _core FdiaGenerator).
|
|
115
|
+
# Importing at module top would force every `import fdia_graph` user to have the
|
|
116
|
+
# optional [generate] extra installed just to call load(); we defer the cost to call time.
|
|
117
|
+
from .generation import generate as _generate
|
|
118
|
+
# Delegate to the real implementation. `system` names the grid (ieee14/118/300),
|
|
119
|
+
# `name` is what the result registers as (so load(name) can retrieve it), and **knobs
|
|
120
|
+
# forwards the research parameters untouched so this wrapper never goes stale as knobs change.
|
|
121
|
+
return _generate(system, name=name, **knobs)
|
fdia_graph/_core.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Dataset generation engine (attack simulation + realistic measurement emission).
|
|
2
|
+
|
|
3
|
+
Refactored from the validated reference generator into a knob-driven class. Requires pandapower
|
|
4
|
+
(pip install 'fdia-graph[generate]'). Benign records are emitted EXACTLY from a stored operating state
|
|
5
|
+
(0-error AC flows, no re-solve); only attacks re-solve a power flow. Attack families:
|
|
6
|
+
Aq stealthy load scaling: bounded per-bus load rescale + AC re-solve with AGC-balanced generation,
|
|
7
|
+
yielding a fully power-flow-consistent counterfactual (stealthy). OUR contribution — the AC/physical
|
|
8
|
+
realization of the state-consistent attack concept (cf. Boyaci et al. 2022 "Ao"; Liu et al. 2011 FDIA)
|
|
9
|
+
At (ramp) temporal creeping load surge that ramps up then back down over a multi-timestep sequence (stealthy);
|
|
10
|
+
ramp attack of Haghshenas, Hasnat & Naeini, "A Temporal Graph Neural Network for Cyber Attack Detection
|
|
11
|
+
and Localization in Smart Grids", IEEE ISGT 2023
|
|
12
|
+
Al (LRA) targeted masked-overload, Yuan/Li/Ren IEEE T-SG 2011 (stealthy)
|
|
13
|
+
Ad/As/Ar measurement-level corruption (BDD-detectable contrast set)
|
|
14
|
+
"""
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
# Integer label for each attack family. These IDs are written into the per-bus label tensor `y`
|
|
18
|
+
# (0 = clean bus, >0 = attacked bus of that family) and consumed downstream by dataset.py.
|
|
19
|
+
# "Ao"/"SLS" are backward-compatible aliases for Aq (id 1) so older scripts/datasets still resolve.
|
|
20
|
+
FAM_ID = {"benign": 0, "Aq": 1, "SLS": 1, "Ao": 1, "Ad": 2, "As": 3, "Ar": 4,
|
|
21
|
+
"At": 5, "ramp": 5, "Al": 6, "LRA": 6} # At=ramp, Al=LRA (with descriptive aliases)
|
|
22
|
+
# Maps a bus-count knob (14/118/300) to the pandapower.networks factory name for that IEEE case.
|
|
23
|
+
_CASE = {14: "case14", 118: "case118", 300: "case300"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
27
|
+
# N-1 LINE OUTAGE SUPPORT
|
|
28
|
+
# A switching event moves the operating manifold: with a line open, the same bus injections produce a
|
|
29
|
+
# different voltage/flow state, so a subspace prior (or any model) fitted on the intact network is being
|
|
30
|
+
# asked to extrapolate. To measure that we need records generated under a genuinely different topology,
|
|
31
|
+
# which means taking the branch out BEFORE anything derived (Ybus, PTDF, the base operating point, the
|
|
32
|
+
# emitted measurements) is computed — a post-hoc mask on an intact-network dataset would not do it.
|
|
33
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
34
|
+
def _line_id(net, outage):
|
|
35
|
+
"""Map a line NAME or index to the pandapower line index, with a clear error if it names nothing."""
|
|
36
|
+
if isinstance(outage, str):
|
|
37
|
+
hit = net.line.index[net.line["name"].astype(str) == outage]
|
|
38
|
+
if len(hit) == 0:
|
|
39
|
+
raise ValueError(f"no line named {outage!r} in this case")
|
|
40
|
+
if len(hit) > 1:
|
|
41
|
+
raise ValueError(f"line name {outage!r} is ambiguous ({len(hit)} matches); pass an index")
|
|
42
|
+
return int(hit[0])
|
|
43
|
+
idx = int(outage)
|
|
44
|
+
if idx not in net.line.index:
|
|
45
|
+
raise ValueError(f"line index {idx} is not in this case (lines are {net.line.index.min()}..{net.line.index.max()})")
|
|
46
|
+
return idx
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _n_islands(net):
|
|
50
|
+
"""Number of connected components over the IN-SERVICE network (1 == still one connected grid).
|
|
51
|
+
|
|
52
|
+
create_nxgraph drops out-of-service branches by default and keeps every bus as a node, so a bus left
|
|
53
|
+
with no live connection shows up as its own component. This is the cheap screen that has to run BEFORE
|
|
54
|
+
generating: pandapower will happily "converge" on an islanded case by silently marking the stranded
|
|
55
|
+
buses isolated (bus type 4) and returning a result, so a solver failure is NOT the signal that a
|
|
56
|
+
contingency was infeasible.
|
|
57
|
+
"""
|
|
58
|
+
import networkx as nx
|
|
59
|
+
from pandapower import topology as top
|
|
60
|
+
return int(nx.number_connected_components(top.create_nxgraph(net)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def line_outage_candidates(system, top_n=5, seed_flow_from=None):
|
|
64
|
+
"""Rank single-line N-1 contingencies by base-case active power flow, keeping the network connected.
|
|
65
|
+
|
|
66
|
+
Returns (accepted, rejected). `accepted` is the `top_n` highest-flow lines whose removal leaves one
|
|
67
|
+
connected, solvable, PTDF-well-posed network, each a dict with the line index, terminal buses, name and
|
|
68
|
+
signed base-case from-end MW flow. `rejected` lists the higher-flow lines that were screened out and
|
|
69
|
+
why, so a caller can report honestly which contingencies exist but cannot be generated.
|
|
70
|
+
|
|
71
|
+
Highest-flow single-line outages are the standard N-1 screening choice (Moshtagh et al. use exactly
|
|
72
|
+
this ranking), which makes the scenario set defensible by citation rather than by taste.
|
|
73
|
+
"""
|
|
74
|
+
import pandapower as pp, pandapower.networks as pn
|
|
75
|
+
from pandapower.pypower.makePTDF import makePTDF
|
|
76
|
+
NET = getattr(pn, _CASE[int(system)])
|
|
77
|
+
base = seed_flow_from if seed_flow_from is not None else NET()
|
|
78
|
+
if "p_from_mw" not in base.res_line or base.res_line.empty:
|
|
79
|
+
pp.runpp(base)
|
|
80
|
+
flow = base.res_line["p_from_mw"].to_numpy()
|
|
81
|
+
lut0 = base._pd2ppc_lookups["bus"]
|
|
82
|
+
order = np.argsort(-np.abs(np.nan_to_num(flow))) # highest |MW| first
|
|
83
|
+
accepted, rejected = [], []
|
|
84
|
+
for pos in order:
|
|
85
|
+
if len(accepted) >= top_n:
|
|
86
|
+
break
|
|
87
|
+
idx = int(base.line.index[pos])
|
|
88
|
+
if not bool(base.line.at[idx, "in_service"]):
|
|
89
|
+
continue # already open in the intact case: not a contingency
|
|
90
|
+
net = NET()
|
|
91
|
+
net.line.at[idx, "in_service"] = False
|
|
92
|
+
why = None
|
|
93
|
+
if _n_islands(net) != 1:
|
|
94
|
+
why = f"removing it splits the grid into {_n_islands(net)} islands"
|
|
95
|
+
else:
|
|
96
|
+
try:
|
|
97
|
+
pp.runpp(net)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
why = f"post-contingency AC power flow does not converge ({type(e).__name__})"
|
|
100
|
+
else:
|
|
101
|
+
n_iso = int((net._ppc["bus"][:, 1].real == 4).sum())
|
|
102
|
+
if n_iso:
|
|
103
|
+
why = f"leaves {n_iso} isolated bus(es)"
|
|
104
|
+
elif not np.array_equal(lut0, net._pd2ppc_lookups["bus"]):
|
|
105
|
+
why = "changes the ppc bus ordering (not comparable to the base shard)"
|
|
106
|
+
else:
|
|
107
|
+
try:
|
|
108
|
+
makePTDF(net._ppc["baseMVA"], net._ppc["bus"], net._ppc["branch"])
|
|
109
|
+
except Exception:
|
|
110
|
+
why = "PTDF is singular (the DC network is islanded)"
|
|
111
|
+
_nm = base.line.at[idx, "name"]
|
|
112
|
+
rec = dict(line=idx, pos=int(pos), from_bus=int(base.line.at[idx, "from_bus"]),
|
|
113
|
+
to_bus=int(base.line.at[idx, "to_bus"]),
|
|
114
|
+
name=(f"line{idx}" if _nm is None or str(_nm) in ("None", "nan", "") else str(_nm)),
|
|
115
|
+
base_flow_mw=float(flow[pos]))
|
|
116
|
+
if why:
|
|
117
|
+
rejected.append({**rec, "reason": why})
|
|
118
|
+
else:
|
|
119
|
+
accepted.append(rec)
|
|
120
|
+
return accepted, rejected
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FdiaGenerator:
|
|
124
|
+
def __init__(self, system, seed=123, vbus_frac=0.6, pmu_frac=0.2, flow_frac=0.90, outage=None):
|
|
125
|
+
# pandapower is an optional/heavy dependency, so import lazily inside the constructor
|
|
126
|
+
# (only when someone actually generates data) rather than at module import time.
|
|
127
|
+
import pandapower as pp, pandapower.networks as pn
|
|
128
|
+
# makeYbus builds the complex nodal admittance matrix Y (the grid's electrical topology);
|
|
129
|
+
# makePTDF builds the Power Transfer Distribution Factors (linear line-flow sensitivities to
|
|
130
|
+
# bus injections) used to target the LRA attack at a specific line.
|
|
131
|
+
from pandapower.pypower.makeYbus import makeYbus
|
|
132
|
+
from pandapower.pypower.makePTDF import makePTDF
|
|
133
|
+
self.pp = pp
|
|
134
|
+
# C = bus count (e.g. 118). rng = seeded generator so the whole dataset is reproducible.
|
|
135
|
+
self.C = int(system); self.rng = np.random.default_rng(seed)
|
|
136
|
+
# Per-quantity measurement noise std-devs (relative for flows/injections, absolute for V/angle).
|
|
137
|
+
# ASPROU ACCURACY-CLASS MEASUREMENT-CHAIN MODEL (Asprou, Kyriakides & Albu 2013 / TIM 2014; the model
|
|
138
|
+
# Falas et al. 2025 delegates to). Each std = manufacturer MAX uncertainty / sqrt(3) (uniform-distribution
|
|
139
|
+
# convention, the paper's eqs 16-21), for CLASS-0.2 instrument transformers (the paper's high-accuracy case):
|
|
140
|
+
# P/Q: conventional measurement-device max +-3% (Table III) -> sigma = 3%/sqrt3 ~= 1.73% (pi/qi/pf/qf)
|
|
141
|
+
# |V|: VT class-0.2 magnitude error 0.2% (Table I) -> sigma = 0.2%/sqrt3 ~= 0.12% (abs, V~1 pu)
|
|
142
|
+
# ang: VT class-0.2 phase displacement 10 arc-min = 0.167 deg -> sigma = 0.167/sqrt3 ~= 0.096 deg
|
|
143
|
+
# (the VT phase displacement dominates the 0.01 deg PMU-device term). va is stored in radians.
|
|
144
|
+
self.SD = dict(pf=0.017, qf=0.017, v=0.0012, pi=0.017, qi=0.017, va=0.00168)
|
|
145
|
+
# ACCURACY vs PRECISION split. A meter's accuracy-class error is mostly SYSTEMATIC (calibration/ratio
|
|
146
|
+
# offset — constant across scans), with only a small RANDOM repeatability jitter scan-to-scan. Modeling
|
|
147
|
+
# all of SD as independent per-scan noise would make 1-minute traces unrealistically jittery (and drown
|
|
148
|
+
# the temporal channel). So we split: a per-meter BIAS drawn ONCE (0.968*SD, constant over time) + a
|
|
149
|
+
# small per-scan JITTER (0.25*SD). RSS(0.968,0.25)=1.0, so the total error still equals the Asprou class.
|
|
150
|
+
_JIT = 0.25
|
|
151
|
+
self.SDj = {k: v * _JIT for k, v in self.SD.items()} # per-scan random jitter std
|
|
152
|
+
self._sd_bias = {k: v * (1.0 - _JIT * _JIT) ** 0.5 for k, v in self.SD.items()} # per-meter bias std
|
|
153
|
+
# Grab the network factory (e.g. pn.case118) for this case.
|
|
154
|
+
self.NET = getattr(pn, _CASE[self.C])
|
|
155
|
+
# N-1 CONTINGENCY. `outage` (a line index or name, None = intact network) takes that line out of
|
|
156
|
+
# service BEFORE the base power flow, so every derived quantity below — Ybus, Yf/Yt, PTDF,
|
|
157
|
+
# edge_status, the base operating point, and therefore every measurement this generator ever emits —
|
|
158
|
+
# describes the post-contingency network. The branch ROW is kept in ppc with status 0, so
|
|
159
|
+
# edge_index, E and the meter plan are identical to the intact case and the two shards are directly
|
|
160
|
+
# comparable: exactly one thing changed.
|
|
161
|
+
self.outage = None; self.outage_pos = -1; self.outage_name = ""
|
|
162
|
+
self.outage_from_bus = -1; self.outage_to_bus = -1; self.outage_base_flow_mw = float("nan")
|
|
163
|
+
base = self.NET()
|
|
164
|
+
if outage is not None:
|
|
165
|
+
self.outage = _line_id(base, outage)
|
|
166
|
+
# Base-case (INTACT) flow on the line we are about to open, recorded so the shard can say how
|
|
167
|
+
# large a contingency it represents.
|
|
168
|
+
intact = self.NET(); pp.runpp(intact)
|
|
169
|
+
self.outage_base_flow_mw = float(intact.res_line.at[self.outage, "p_from_mw"])
|
|
170
|
+
self.outage_pos = int(base.line.index.get_loc(self.outage))
|
|
171
|
+
# IEEE cases from pandapower.networks carry no line names (the column is None), so fall back to
|
|
172
|
+
# a readable "line<idx>" tag rather than storing the string "None" in the shard attrs.
|
|
173
|
+
_nm = base.line.at[self.outage, "name"]
|
|
174
|
+
self.outage_name = f"line{self.outage}" if _nm is None or str(_nm) in ("None", "nan", "") else str(_nm)
|
|
175
|
+
self.outage_from_bus = int(base.line.at[self.outage, "from_bus"])
|
|
176
|
+
self.outage_to_bus = int(base.line.at[self.outage, "to_bus"])
|
|
177
|
+
base.line.at[self.outage, "in_service"] = False
|
|
178
|
+
# Refuse an islanding contingency up front. pandapower would otherwise "converge" with the
|
|
179
|
+
# stranded buses silently marked isolated and hand back a state that is not a power flow
|
|
180
|
+
# solution of anything, and makePTDF would raise a singular matrix mid-build.
|
|
181
|
+
if _n_islands(base) != 1:
|
|
182
|
+
raise ValueError(f"line {self.outage} outage splits the grid into {_n_islands(base)} islands; "
|
|
183
|
+
f"screen with line_outage_candidates() before generating")
|
|
184
|
+
# Solve the (possibly post-contingency) AC power flow to get this topology's base operating point.
|
|
185
|
+
pp.runpp(base)
|
|
186
|
+
if self.outage is not None:
|
|
187
|
+
n_iso = int((base._ppc["bus"][:, 1].real == 4).sum())
|
|
188
|
+
if n_iso:
|
|
189
|
+
raise ValueError(f"line {self.outage} outage leaves {n_iso} isolated bus(es)")
|
|
190
|
+
self.base = base
|
|
191
|
+
C = self.C
|
|
192
|
+
# load_bus = the bus of EVERY load element, aligned 1:1 with the net.load table so the AC re-solve
|
|
193
|
+
# (solve() writes net.load["p_mw"] = Lp) and the PTDF-over-load-buses arrays all stay the same length.
|
|
194
|
+
_lb = base.load
|
|
195
|
+
self.load_bus = _lb["bus"].values
|
|
196
|
+
# ATTACKABLE subset = positions in load_bus with real ACTIVE-power load (|p_mw| > 0). A few buses (e.g.
|
|
197
|
+
# IEEE-300 141, 183) carry a reactive-only load (p_mw=0, q_mvar!=0): pandapower lists them as loads, but our
|
|
198
|
+
# attacks scale/redistribute ACTIVE power, so attacking one leaves no P footprint yet still gets a y=1 label
|
|
199
|
+
# (the "no-load bus getting attacked" case). We keep them in the full load table for the physics but exclude
|
|
200
|
+
# them from attack TARGET selection (generation.py) and from the LRA candidate set (_lra_for_line).
|
|
201
|
+
self._attackable_mask = _lb["p_mw"].abs().values > 0.0
|
|
202
|
+
self.attackable_pos = np.where(self._attackable_mask)[0]
|
|
203
|
+
# Every bus that has SOME injection element attached (generator, load, slack/ext_grid, shunt).
|
|
204
|
+
inj = np.unique(np.r_[base.gen.bus.values, base.load.bus.values, base.ext_grid.bus.values, base.shunt.bus.values])
|
|
205
|
+
# Zero-injection buses: pure junctions with no source/sink. Their net injection is physically
|
|
206
|
+
# exactly 0, a strong known constraint — we still emit a (near-zero) injection measurement there.
|
|
207
|
+
self.zero_inj = [b for b in range(C) if b not in set(inj)]
|
|
208
|
+
# Sparse-metering plan (which buses are actually instrumented), sampled once per generator:
|
|
209
|
+
# vbus = buses with a voltage-magnitude meter (fraction vbus_frac)
|
|
210
|
+
# pmu = buses with a PMU (gives voltage magnitude AND phase angle) (fraction pmu_frac)
|
|
211
|
+
# inj = buses whose P/Q injection is metered (all injection buses)
|
|
212
|
+
self.M = dict(vbus=set(self.rng.choice(C, int(vbus_frac*C), replace=False).tolist()),
|
|
213
|
+
pmu=set(self.rng.choice(C, max(1, int(pmu_frac*C)), replace=False).tolist()),
|
|
214
|
+
inj=sorted(set(inj.tolist())))
|
|
215
|
+
# Boolean mask over all branches (lines + trafos): which branches have a flow meter (fraction
|
|
216
|
+
# flow_frac). Branches without a meter emit no edge feature (masked out).
|
|
217
|
+
self.flow_meter = self.rng.random(len(base.line)+len(base.trafo)) < flow_frac
|
|
218
|
+
# Edge index (2 x E): row 0 = from-bus, row 1 = to-bus. Lines use from/to; trafos use hv/lv.
|
|
219
|
+
# The two element types are concatenated so branches share one contiguous indexing 0..E-1.
|
|
220
|
+
self.ei = np.vstack([np.r_[base.line.from_bus.values, base.trafo.hv_bus.values],
|
|
221
|
+
np.r_[base.line.to_bus.values, base.trafo.lv_bus.values]]).astype(np.int32)
|
|
222
|
+
# E = total branch count; nl = number of lines (first nl columns of ei are lines).
|
|
223
|
+
self.E = self.ei.shape[1]; self.nl = len(base.line)
|
|
224
|
+
# DEPRECATED as of v0.5.0, retained so existing code keeps loading. This mixes UNITS: lines
|
|
225
|
+
# carry series reactance in ohms while transformers carry vk_percent, a short-circuit voltage
|
|
226
|
+
# percentage. On IEEE-300 that puts transformer entries three orders of magnitude above line
|
|
227
|
+
# entries for reasons that are not physical, and 128 of 411 branches are transformers. Use the
|
|
228
|
+
# per-unit edge_* arrays below instead.
|
|
229
|
+
self.x_react = np.r_[base.line.x_ohm_per_km.values*base.line.length_km.values, base.trafo.vk_percent.values].astype(np.float32)
|
|
230
|
+
# pandapower's internal PYPOWER case (ppc) holds the numeric bus/branch arrays in ppc ordering.
|
|
231
|
+
ppc = base._ppc
|
|
232
|
+
|
|
233
|
+
# FULL BRANCH AND BUS PHYSICS, per unit, exactly the quantities makeYbus itself consumes, so a
|
|
234
|
+
# model reading them has the same information the state estimator has. ppc branch rows are
|
|
235
|
+
# ordered lines-then-transformers, which is the same order as self.ei, verified by the branch
|
|
236
|
+
# count matching len(line)+len(trafo) on all three systems.
|
|
237
|
+
# BR_G (column 23) is a pandapower extension absent from stock PYPOWER and carries transformer
|
|
238
|
+
# iron losses. Omitting it reconstructs Ybus exactly on IEEE-14, which has none, and wrongly on
|
|
239
|
+
# IEEE-118 and IEEE-300, which have 4 and 18. That is the kind of error that looks correct on
|
|
240
|
+
# the system people test with first, so it is called out here.
|
|
241
|
+
# float64, not float32. These arrays are a few hundred entries, so the storage cost is a few
|
|
242
|
+
# kilobytes, while float32 rounding degrades the Ybus reconstruction from ~1e-14 to ~1e-4 on
|
|
243
|
+
# IEEE-300. An exact reconstruction is the whole claim, so precision wins over a trivial saving.
|
|
244
|
+
_br = ppc["branch"]
|
|
245
|
+
_tap = _br[:, 8].real.astype(np.float64).copy()
|
|
246
|
+
_tap[_tap == 0] = 1.0 # PYPOWER reads a zero tap entry as unity
|
|
247
|
+
self.edge_r = _br[:, 2].real.astype(np.float64) # series resistance, p.u.
|
|
248
|
+
self.edge_x = _br[:, 3].real.astype(np.float64) # series reactance, p.u.
|
|
249
|
+
self.edge_b = _br[:, 4].real.astype(np.float64) # charging susceptance, p.u.
|
|
250
|
+
self.edge_g = _br[:, 23].real.astype(np.float64) # charging conductance, p.u. (iron losses)
|
|
251
|
+
self.edge_tap = _tap # transformer turns ratio, 1.0 for lines
|
|
252
|
+
self.edge_shift = _br[:, 9].real.astype(np.float64) # phase shift, degrees
|
|
253
|
+
self.edge_status = _br[:, 10].real.astype(np.float64) # 1 in service, 0 out
|
|
254
|
+
self.edge_is_trafo = np.r_[np.zeros(self.nl), np.ones(self.E - self.nl)].astype(np.float64)
|
|
255
|
+
# Shunts sit on the Ybus DIAGONAL, so they are a BUS property and were not expressible in an
|
|
256
|
+
# edge-only schema. Stored in MW and MVAr at 1.0 p.u. voltage, matching the ppc convention.
|
|
257
|
+
self.bus_shunt_g = ppc["bus"][:, 4].real.astype(np.float64)
|
|
258
|
+
self.bus_shunt_b = ppc["bus"][:, 5].real.astype(np.float64)
|
|
259
|
+
# Y = nodal admittance; Yf/Yt = "from"/"to" branch-admittance matrices such that the complex
|
|
260
|
+
# branch flow entering from the from-end is Sf = V_from * conj(Yf @ V).
|
|
261
|
+
self._Ybus, self._Yf, self._Yt = makeYbus(ppc["baseMVA"], ppc["bus"], ppc["branch"])
|
|
262
|
+
# baseMVA = per-unit power base (multiply p.u. by this to get MW/MVAr).
|
|
263
|
+
# _lut maps pandapower bus index -> ppc row index (the two orderings differ — classic footgun).
|
|
264
|
+
self._bMVA = ppc["baseMVA"]; self._lut = base._pd2ppc_lookups["bus"]
|
|
265
|
+
# From-bus (ppc index) of each branch, and the ppc bus count (Vc is built in ppc ordering).
|
|
266
|
+
self._fb = ppc["branch"][:, 0].real.astype(int); self._nppc = ppc["bus"].shape[0]
|
|
267
|
+
# Total generator MW dispatched at each bus (summing multiple gens on the same bus).
|
|
268
|
+
genP = {}
|
|
269
|
+
for r in base.gen.itertuples(): genP[int(r.bus)] = genP.get(int(r.bus), 0.0) + r.p_mw
|
|
270
|
+
# Gen MW aligned to the load-bus ordering — lets attacks reason about net (load - gen) at a bus.
|
|
271
|
+
self.load_genP = np.array([genP.get(int(b), 0.0) for b in self.load_bus])
|
|
272
|
+
# PTDF: rows = branches, cols = buses; entry = sensitivity of that branch's MW flow to an
|
|
273
|
+
# injection at that bus. Linear DC model, used to steer redistribution toward a target line.
|
|
274
|
+
self._ptdf = makePTDF(self._bMVA, ppc["bus"], ppc["branch"])
|
|
275
|
+
# Slice PTDF to (branches x load-buses): reindex ppc-order back to pandapower bus order via _lut,
|
|
276
|
+
# then keep only the load-bus columns (loads are the levers an LRA/Ao attack can move).
|
|
277
|
+
self._ptdf_lb = self._ptdf[:, [self._lut[b] for b in range(C)]][:, self.load_bus]
|
|
278
|
+
# A reusable network instance for re-solving power flows under attacked loads (avoids rebuilds).
|
|
279
|
+
# The contingency has to be applied here too, or attacked records would be solved on the INTACT
|
|
280
|
+
# network while benign records came from the post-contingency one.
|
|
281
|
+
self._solvenet = self.NET()
|
|
282
|
+
if self.outage is not None:
|
|
283
|
+
self._solvenet.line.at[self.outage, "in_service"] = False
|
|
284
|
+
# Per-meter SYSTEMATIC BIAS, drawn ONCE here so it is CONSTANT across every scan (a fixed calibration
|
|
285
|
+
# offset per meter). Relative for P/Q & flows (a fixed fraction of the reading), absolute for V/angle.
|
|
286
|
+
# This is the slow/systematic part of the accuracy-class error; the small per-scan jitter (self.SDj) is
|
|
287
|
+
# added fresh at emit time. Together they realize the class total while keeping 1-min traces smooth.
|
|
288
|
+
sb = self._sd_bias
|
|
289
|
+
self.bias_pi = self.rng.normal(0, sb["pi"], self.C); self.bias_qi = self.rng.normal(0, sb["qi"], self.C)
|
|
290
|
+
self.bias_v = self.rng.normal(0, sb["v"], self.C); self.bias_va = self.rng.normal(0, sb["va"], self.C)
|
|
291
|
+
self.bias_pf = self.rng.normal(0, sb["pf"], self.E); self.bias_qf = self.rng.normal(0, sb["qf"], self.E)
|
|
292
|
+
# Buffer of recent benign records — replay attacks (Ar) copy an earlier clean snapshot from here.
|
|
293
|
+
self.benign_buf = []
|
|
294
|
+
|
|
295
|
+
# ---- emission ----
|
|
296
|
+
# Draw one zero-mean Gaussian noise sample with std `s` (the meter-noise primitive).
|
|
297
|
+
def _n(self, s): return self.rng.normal(0, s)
|
|
298
|
+
|
|
299
|
+
def emit_from_state(self, X):
|
|
300
|
+
# Emit a measurement graph DIRECTLY from a stored operating state X (no power-flow re-solve),
|
|
301
|
+
# giving physically exact (0-error) flows before meter noise. X columns = [Pinj, Qinj, |V|, angle].
|
|
302
|
+
C, SD, M = self.C, self.SD, self.M
|
|
303
|
+
Pi, Qi, V, TH = X[:, 0], X[:, 1], X[:, 2], X[:, 3]
|
|
304
|
+
# Rebuild the complex bus-voltage phasor vector in ppc ordering: V * e^{j*theta}.
|
|
305
|
+
Vc = np.zeros(self._nppc, complex)
|
|
306
|
+
for b in range(C): Vc[self._lut[b]] = V[b]*np.exp(1j*np.deg2rad(TH[b]))
|
|
307
|
+
# Exact complex branch "from-end" power flow via the physics identity Sf = V_from * conj(Yf @ V),
|
|
308
|
+
# scaled by baseMVA to physical units. Sf.real = P_from (MW), Sf.imag = Q_from (MVAr).
|
|
309
|
+
Sf = Vc[self._fb]*np.conj(self._Yf@Vc)*self._bMVA
|
|
310
|
+
# Node feature/mask buffers: columns [|V|, P_inj, Q_inj, angle]; mask=1 where a meter exists.
|
|
311
|
+
nx = np.zeros((C, 4), np.float32); nm = np.zeros((C, 4), np.uint8)
|
|
312
|
+
# Each reading = true + CONSTANT per-meter bias (self.bias_*, drawn once) + per-scan JITTER (self.SDj).
|
|
313
|
+
# V-magnitude & flow biases are relative to the reading; V/angle biases are absolute (VT ratio error /
|
|
314
|
+
# phase displacement). va bias/jitter are in radians -> converted to degrees to match TH.
|
|
315
|
+
SDj = self.SDj
|
|
316
|
+
for b in range(C):
|
|
317
|
+
# Voltage magnitude AND phase angle are observed at the same buses (vbus OR pmu).
|
|
318
|
+
if b in M["vbus"] or b in M["pmu"]:
|
|
319
|
+
nx[b, 0] = V[b] + self.bias_v[b] + self._n(SDj["v"]); nm[b, 0] = 1
|
|
320
|
+
nx[b, 3] = TH[b] + np.degrees(self.bias_va[b]) + self._n(np.degrees(SDj["va"])); nm[b, 3] = 1
|
|
321
|
+
# Injection buses (and zero-injection junctions) emit P/Q: relative bias (fraction of reading) +
|
|
322
|
+
# relative per-scan jitter (+ small floor so a ~0 injection still gets a tiny nonzero std).
|
|
323
|
+
if b in M["inj"] or b in self.zero_inj:
|
|
324
|
+
nx[b, 1] = Pi[b]*(1.0+self.bias_pi[b]) + self._n(abs(Pi[b])*SDj["pi"]+1e-3)
|
|
325
|
+
nx[b, 2] = Qi[b]*(1.0+self.bias_qi[b]) + self._n(abs(Qi[b])*SDj["qi"]+1e-3); nm[b, 1:3] = 1
|
|
326
|
+
# Edge feature/mask buffers: columns [P_from, Q_from]; mask=1 where a flow meter exists.
|
|
327
|
+
ex = np.zeros((self.E, 2), np.float32); em = np.zeros((self.E, 2), np.uint8)
|
|
328
|
+
for e in range(self.E):
|
|
329
|
+
if self.flow_meter[e]:
|
|
330
|
+
# Metered branch flow: relative per-meter bias + relative per-scan jitter on P and Q.
|
|
331
|
+
ex[e, 0] = Sf.real[e]*(1.0+self.bias_pf[e]) + self._n(abs(Sf.real[e])*SDj["pf"]+1e-3)
|
|
332
|
+
ex[e, 1] = Sf.imag[e]*(1.0+self.bias_qf[e]) + self._n(abs(Sf.imag[e])*SDj["qf"]+1e-3); em[e] = 1
|
|
333
|
+
return nx, nm, ex, em
|
|
334
|
+
|
|
335
|
+
def state_from_net(self, net):
|
|
336
|
+
# Pull the operating state [N,4] = [Pinj, Qinj, |V|, theta] out of a SOLVED net, in the same
|
|
337
|
+
# convention the stored pool uses.
|
|
338
|
+
Pi = net.res_bus.p_mw.values.copy(); Qi = net.res_bus.q_mvar.values.copy()
|
|
339
|
+
# Shunts are modeled inside res_bus; subtract shunt draw so Pi/Qi reflect gen/load injection only,
|
|
340
|
+
# matching how the stored states (and emit_from_state) define the injection.
|
|
341
|
+
for i in net.shunt.index:
|
|
342
|
+
b = net.shunt.at[i, "bus"]; Pi[b] -= net.res_shunt.p_mw[i]; Qi[b] -= net.res_shunt.q_mvar[i]
|
|
343
|
+
V = net.res_bus.vm_pu.values; TH = net.res_bus.va_degree.values
|
|
344
|
+
return np.column_stack([Pi, Qi, V, TH]) # [N,4] = [Pinj, Qinj, |V|, theta]
|
|
345
|
+
|
|
346
|
+
def emit(self, net):
|
|
347
|
+
# Emit a measurement graph from a SOLVED pandapower net (attacks that re-solve a power flow). We
|
|
348
|
+
# extract the solved operating state and route it through emit_from_state, so an attacked sample and
|
|
349
|
+
# a benign sample are computed by the IDENTICAL measurement function. Emitting flows here from
|
|
350
|
+
# res_line while benign used the Ybus identity left a ~7 MW systematic benign-vs-attack offset that
|
|
351
|
+
# was not the attack; sharing one path removes it, so an alpha=1 (no-op) re-solve matches benign.
|
|
352
|
+
return self.emit_from_state(self.state_from_net(net))
|
|
353
|
+
|
|
354
|
+
def resolve_states(self, X):
|
|
355
|
+
"""Re-solve a pool of operating points [T,N,4] under THIS generator's topology.
|
|
356
|
+
|
|
357
|
+
A stored state carries the injections AND the voltages that the INTACT network produced for them.
|
|
358
|
+
Under a contingency the same loads give a different voltage/flow state, so emitting an intact-network
|
|
359
|
+
state through a post-contingency Ybus would fabricate measurements that satisfy no power flow at all.
|
|
360
|
+
Re-solving fixes that: the loads (and the generator dispatch reconstructed from the stored state) are
|
|
361
|
+
held at exactly the values the base case had at that timestamp, and only the topology differs — which
|
|
362
|
+
is the whole point, since a load profile that shifted between scenarios would confound topology with
|
|
363
|
+
load level and make the comparison meaningless.
|
|
364
|
+
|
|
365
|
+
Returns (Xnew [T,N,4], ok [T] bool). Rows where the power flow did not converge, or came back
|
|
366
|
+
non-finite, are left as-is and flagged False rather than quietly dropped, so the caller can intersect
|
|
367
|
+
the converged sets across scenarios and keep one common timestamp axis.
|
|
368
|
+
"""
|
|
369
|
+
X = np.asarray(X, dtype=np.float64)
|
|
370
|
+
out = X.copy(); ok = np.zeros(len(X), bool)
|
|
371
|
+
for t in range(len(X)):
|
|
372
|
+
Xt = X[t]
|
|
373
|
+
# Base active load at each load element = stored injection + the generation folded onto that bus.
|
|
374
|
+
Lp = Xt[self.load_bus, 0] + self.load_genP
|
|
375
|
+
Lq = Xt[self.load_bus, 1].copy()
|
|
376
|
+
# Lp_true == Lp: no attack, so this is the alpha=1 no-op re-solve. On the INTACT topology it
|
|
377
|
+
# reproduces the stored state (that identity is the check that the pinning is right); on a
|
|
378
|
+
# contingency topology it is the post-contingency state for the same operating conditions.
|
|
379
|
+
net = self.solve(Lp, Lq, Xt=Xt, Lp_true=Lp)
|
|
380
|
+
if net is None:
|
|
381
|
+
continue
|
|
382
|
+
s = self.state_from_net(net)
|
|
383
|
+
if not np.isfinite(s).all():
|
|
384
|
+
continue
|
|
385
|
+
out[t] = s; ok[t] = True
|
|
386
|
+
return out, ok
|
|
387
|
+
|
|
388
|
+
def solve(self, Lp, Lq, Xt=None, Lp_true=None):
|
|
389
|
+
# Set new load P/Q on the reusable net and re-run AC power flow. Returns the solved net, or None
|
|
390
|
+
# if it fails to converge (attacks push loads into non-convergent regions — caller skips those).
|
|
391
|
+
net = self._solvenet
|
|
392
|
+
net.load["p_mw"] = Lp; net.load["q_mvar"] = Lq
|
|
393
|
+
# Pin the generation to the TRUE operating point's dispatch. Without this the re-solve leaves every
|
|
394
|
+
# generator at its base-case setpoint and dumps the whole load change onto the slack bus, so even a
|
|
395
|
+
# zero-attack re-solve drifts far from the true state (a large slack/generator residual that is NOT
|
|
396
|
+
# the attack). We reconstruct each bus's true generation from the stored injection, Pgen[b] = (true
|
|
397
|
+
# load at b) - (true injection Xt[b,0]), hold it fixed at the UNATTACKED dispatch, and let only the
|
|
398
|
+
# slack absorb the attack's load delta — so an alpha=1 re-solve reproduces the true state and an
|
|
399
|
+
# attack's residual collapses to its actual load footprint plus the minimal slack response.
|
|
400
|
+
if Xt is not None:
|
|
401
|
+
base_load = Lp_true if Lp_true is not None else Lp # unattacked load -> the fixed dispatch
|
|
402
|
+
Lfull = np.zeros(self.C) # total true load per bus (bus-indexed)
|
|
403
|
+
for val, b in zip(base_load, self.load_bus): Lfull[int(b)] += val
|
|
404
|
+
Pinj_true = Xt[:, 0]
|
|
405
|
+
gbus = net.gen["bus"].values
|
|
406
|
+
ncnt = {}
|
|
407
|
+
for b in gbus: ncnt[int(b)] = ncnt.get(int(b), 0) + 1
|
|
408
|
+
# gen bus injection reproduced: net.load(=Lfull+foldedgen) - gen = Xt[b,0]; split across co-located gens
|
|
409
|
+
gp = np.array([(Lfull[int(b)] - Pinj_true[int(b)]) / ncnt[int(b)] for b in gbus], float)
|
|
410
|
+
# Distribute the attack's net load change across generators (participation factor proportional to
|
|
411
|
+
# dispatch, like AGC) instead of dumping it all on the slack. This keeps the counterfactual a
|
|
412
|
+
# plausible, generation-balanced operating point — the most stealthy realization — so the attack's
|
|
413
|
+
# measurement footprint is the attacked loads plus a small spread, not a large single-bus slack spike.
|
|
414
|
+
dL = float(np.sum(Lp) - np.sum(base_load)) # net extra load introduced by the attack
|
|
415
|
+
tot = gp.sum()
|
|
416
|
+
if tot > 0 and dL != 0.0: gp = gp + dL * (gp / tot)
|
|
417
|
+
net.gen["p_mw"] = gp
|
|
418
|
+
net.gen["vm_pu"] = [Xt[int(b), 2] for b in gbus] # hold each gen at its true voltage setpoint
|
|
419
|
+
sb = net.ext_grid["bus"].values # pin the slack reference to the true voltage/angle
|
|
420
|
+
net.ext_grid["vm_pu"] = [Xt[int(b), 2] for b in sb]
|
|
421
|
+
net.ext_grid["va_degree"] = [Xt[int(b), 3] for b in sb]
|
|
422
|
+
try: self.pp.runpp(net); return net
|
|
423
|
+
except Exception: return None
|
|
424
|
+
|
|
425
|
+
def corrupt(self, nx, ex, atk, kind, replay):
|
|
426
|
+
# Measurement-level attacks (the BDD-DETECTABLE contrast families). They perturb the already-emitted
|
|
427
|
+
# measurements at attacked buses `atk` and their incident branches, WITHOUT respecting power-flow
|
|
428
|
+
# physics — which is exactly why bad-data detection can catch them.
|
|
429
|
+
# Incident edges: any branch with either endpoint in the attacked-bus set.
|
|
430
|
+
inc = [e for e in range(self.E) if self.ei[0, e] in atk or self.ei[1, e] in atk]
|
|
431
|
+
for b in atk:
|
|
432
|
+
# Ad = random additive corruption: large relative Gaussian noise on P/Q injection + a jolt on V.
|
|
433
|
+
if kind == "Ad": nx[b, 1:3] += self.rng.normal(0, 0.3*np.abs(nx[b, 1:3])+0.05, 2); nx[b, 0] += self.rng.normal(0, 0.02)
|
|
434
|
+
# As = scaling attack: multiply injections by a 1.25-1.5x gain.
|
|
435
|
+
elif kind == "As": nx[b, 1:3] *= self.rng.uniform(1.25, 1.5)
|
|
436
|
+
# Ar = replay: overwrite this bus's features with a stored earlier (clean) snapshot's values.
|
|
437
|
+
elif kind == "Ar" and replay is not None: nx[b, :] = replay[b, :]
|
|
438
|
+
for e in inc:
|
|
439
|
+
# Apply the matching corruption to incident branch flow measurements (Ad additive, As scaling).
|
|
440
|
+
if kind == "Ad": ex[e] += self.rng.normal(0, 0.3*np.abs(ex[e])+0.05, 2)
|
|
441
|
+
elif kind == "As": ex[e] *= self.rng.uniform(1.25, 1.5)
|
|
442
|
+
return nx, ex
|
|
443
|
+
|
|
444
|
+
# ---- LRA (Yuan et al. 2011) target line + delta ----
|
|
445
|
+
def _lra_for_line(self, L, Lp, rel, K, rand=False):
|
|
446
|
+
# rand=True samples attacked buses from the top-2K high-PTDF candidates so the bus SET varies per
|
|
447
|
+
# record (not memorizable); rand=False (target ranking) stays deterministic.
|
|
448
|
+
# Load Redistribution Attack for a chosen target line L: craft a load-injection delta that is
|
|
449
|
+
# (a) LOAD-CONSERVING (total load unchanged -> looks like a normal re-dispatch), (b) PER-BUS
|
|
450
|
+
# BOUNDED (|delta_b| <= rel*|Lp_b|, physically plausible), and (c) steers flow on line L via PTDF.
|
|
451
|
+
# pl = PTDF row for line L over load buses (each load's marginal effect on line-L flow).
|
|
452
|
+
pl = self._ptdf_lb[L]; cap = rel*np.abs(Lp); score = np.abs(pl)*cap
|
|
453
|
+
def pick(side):
|
|
454
|
+
# Rank candidate buses on one PTDF sign side by score (how much flow-change each can buy) and
|
|
455
|
+
# keep the strongest K (deterministic) or a random K of the top-2K (randomized).
|
|
456
|
+
side = side[np.argsort(-score[side])]
|
|
457
|
+
if len(side) == 0: return side
|
|
458
|
+
top = side[:2*K]; k = min(K, len(top))
|
|
459
|
+
return self.rng.choice(top, k, replace=False) if rand else top[:k]
|
|
460
|
+
# Split load buses by PTDF sign: positive buses increase line-L flow, negative buses decrease it.
|
|
461
|
+
# We RAISE load on the positive side and DROP it on the negative side to push flow up on line L.
|
|
462
|
+
# Restrict to ATTACKABLE (active-load) buses so a reactive-only bus is never redistributed onto / labelled.
|
|
463
|
+
pos = pick(np.where((pl > 0) & self._attackable_mask)[0]); neg = pick(np.where((pl < 0) & self._attackable_mask)[0])
|
|
464
|
+
if len(pos) == 0 or len(neg) == 0: return None
|
|
465
|
+
# Per-bus caps for each side; the conserved budget is the smaller of the two side capacities so the
|
|
466
|
+
# raise on `pos` can be exactly cancelled by the drop on `neg` (net load change = 0).
|
|
467
|
+
up, dn = cap[pos].copy(), cap[neg].copy(); budget = min(up.sum(), dn.sum())
|
|
468
|
+
if budget <= 0: return None
|
|
469
|
+
# Scale each side to hit exactly `budget` MW moved, preserving per-bus proportions.
|
|
470
|
+
up *= budget/up.sum(); dn *= budget/dn.sum()
|
|
471
|
+
# Assemble the full load-delta vector: +up on positive buses, -dn on negative buses (sums to 0).
|
|
472
|
+
d = np.zeros_like(Lp); d[pos] = up; d[neg] = -dn
|
|
473
|
+
# Return (delta, attacked-bus indices, achieved line-L flow change = -sum(PTDF*delta)).
|
|
474
|
+
return d, np.r_[pos, neg], float(-np.sum(pl*d))
|
|
475
|
+
|
|
476
|
+
def _pick_lra_target(self, rel, K, n_targets=15):
|
|
477
|
+
# Rank lines by achievable conserving-redistribution flow change and keep the top-`n_targets` as a
|
|
478
|
+
# target POOL. Varying the target per attack (below) diversifies the attacked-bus set so LRA is not
|
|
479
|
+
# trivially localizable (a single fixed target lets a model just memorize those buses).
|
|
480
|
+
# Use base-case loads to evaluate each line's attack potential once, up front.
|
|
481
|
+
bl = self.base.load.p_mw.values
|
|
482
|
+
# For every line, compute its best conserving delta and the flow change it achieves. An outaged line
|
|
483
|
+
# is skipped explicitly: its PTDF row is zero so it would rank last anyway, but its base-case flow is
|
|
484
|
+
# NaN, and a NaN reaching self._sgn would silently poison every LRA delta on that line.
|
|
485
|
+
pot = [(L, self._lra_for_line(L, bl, rel, K)) for L in range(self.nl) if L != self.outage_pos]
|
|
486
|
+
pot = [(L, r) for L, r in pot if r is not None]
|
|
487
|
+
# Sort by |achieved flow change| descending — the most attackable lines first.
|
|
488
|
+
pot.sort(key=lambda x: -abs(x[1][2]))
|
|
489
|
+
# Keep the top-n as the candidate target pool.
|
|
490
|
+
self._Lcands = [L for L, _ in pot[:min(n_targets, len(pot))]]
|
|
491
|
+
# Sign of each candidate line's base flow (fallback +1) so the attack pushes flow in the direction
|
|
492
|
+
# that WORSENS the existing loading (masking a real overload rather than relieving it).
|
|
493
|
+
self._sgn = {L: (float(np.sign(self.base.res_line.p_from_mw.values[L])) or 1.0) for L in self._Lcands}
|
|
494
|
+
# Default/primary target = most attackable line.
|
|
495
|
+
self._Ltgt = self._Lcands[0]
|
|
496
|
+
|
|
497
|
+
def lra_delta(self, Lp, rel, K):
|
|
498
|
+
L = int(self.rng.choice(self._Lcands)) # random target line per attack
|
|
499
|
+
r = self._lra_for_line(L, Lp, rel, K, rand=True) # + randomized bus subset -> not memorizable
|
|
500
|
+
# Apply the line's base-flow sign so the redistribution masks (not relieves) its overload; if no
|
|
501
|
+
# feasible delta exists, return a zero delta and empty attacked-bus set (record stays effectively benign).
|
|
502
|
+
return (r[0]*self._sgn[L], r[1]) if r is not None else (np.zeros_like(Lp), np.array([], int))
|