Skip to content

About

Causal intrusion detection for smart power grids: a GNN perception layer feeding a Dynamic Bayesian Network over a pandapower physics loop, stress-tested against a trained PPO adversarial attacker. 530 tests, 12 pre-registered experiments.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Cyber-Physical DBN with Learned Perception and Closed Physical Loop

Causal intrusion detection for power grids — learned instead of hand-tuned, closed-loop instead of open, honestly evaluated instead of oversold.

Python PyTorch pandapower Tests Experiments Figures LOC

Overview · Novelty · Architecture · Key equations · Claims & results · Real-data results · Experiment suite · Quickstart · Module map · Findings · Citation


Overview

This project extends Cerotti, D. et al., "Dynamic Bayesian Networks for the Detection and Analysis of Cyber Attacks to Power Systems," IEEE Access, vol. 13, pp. 186289–186306, 2025 — a Dynamic Bayesian Network (DBN) that models causal dependencies between MITRE-ATT&CK attack steps and detection analytics in a power system, using uniformization-based CPT parameterization and Boyen–Koller (BK) approximate inference under three clustering strategies: exact (EX), heuristic-clustered (CL), and fully factorized (FF).

The source paper's causal formalism is preserved exactly — same 2TBN structure, same uniformization equation, same BK clustered-inference engine (run in its EX and FF configurations; the paper's heuristic CL clustering was not re-run, since the paper itself reports FF ≈ CL) — and reproduced against its own published reference numbers before any extension work began (experiments/exp01_reproduce_paper.py, gate result below). What this project changes is everything feeding that formalism: in the source paper every CPT parameter and every piece of evidence is hand-assigned by a domain expert, with no physical feedback loop. Here, a digital twin generates the evidence, a graph neural network perceives it, a second neural model learns the timing parameters, and a simulated power grid closes a physical feedback loop the source paper never attempts.

The project is organized as nineteen sequential experiments (exp01 through exp19; 17 scripts, two of which are config variants), each gated by a structural validation check before its numbers are trusted, and every hypothesis pre-registered in LAB_NOTEBOOK.md before the corresponding code was written — the standard lab-notebook discipline of "hypothesis before, finding after," applied to a software research project.

Novelty beyond the source paper

Source paper (Cerotti et al.) This project
Grid physics Instability asserted by a hand-picked probability Instability measured from a real pandapower power-flow solve, fed back as evidence — a closed loop (C1)
Attack-step timing (TTC) Hand-elicited from a static Table 3, no stated derivation Learned by a GNN from digital-twin executions; transfers to unseen attack graphs with zero expert input (C2)
Evaluation baseline Compares only its own inference variants (EX / CL / FF) Adds four external baselines (gradient-boosted trees, rule-based, LSTM-AE, GNN classifier) — and reports honestly when they win on raw accuracy
Real-world grounding None — twin/simulation only All three real Sherlock networks (ACM CODASPY 2025): physical snapshots and raw IEC-104 packet captures; a label-free detector fitted on two networks transfers to a third, never-seen one (ROC-AUC 0.75); the twin↔real-data gap is reported as a finding, not hidden
Adversarial robustness Flagged as a future concern, never tested An RL attacker (PPO, three escalating knowledge levels: blind → analytics-aware → full-DBN-aware) is trained specifically to evade the detector (C3)
Perception evidence Fixed p_pos = p_neg = 10⁻⁴ for every analytic, regardless of true reliability Learned, temperature-calibrated per-analytic likelihoods entered as virtual/soft evidence

Every comparison above is backed by a logged, reproducible experiment — not a claim. docs/literature_review.md traces each design decision to a specific external source (journal papers, ACM/IEEE conference proceedings, arXiv preprints, GitHub repositories, and the MITRE ATT&CK for ICS knowledge base), stating what was reused unchanged, what was extended, and what gap it fills.

Architecture

System architecture: seven layers from digital twin through perception, parameterization, DBN causal core, physical consequence, decision, and explanation
[0] Digital twin        pandapower grid + abstracted comms + attacker/defender agents
                         → labeled telemetry, ground-truth attack state, physical outcome
[1] Perception           Heterogeneous GNN over the cyber-physical asset graph
                         + temporal encoder → calibrated per-analytic likelihoods
                         → SOFT EVIDENCE into the DBN
[2] Parameterization     GNN/hypernetwork: (technique, asset context, defenses) → TTC
                         → p_s via uniformization
[3] DBN causal core      2TBN + FF/BK inference. Consumes soft evidence + learned CPTs.
                         Emits posteriors, causal paths, counterfactuals.
[4] Physical             Compromised control actions execute in the twin → voltage/
    consequence          frequency evolve → instability MEASURED not asserted
                         → physical deviation returns as evidence — THE CLOSED LOOP
[5] Decision (stretch)   DBN posterior as POMDP belief state → RL defense policy
[6] Explanation (opt.)   Max-posterior causal path → LLM analyst narrative

Division of labor, never violated: neural networks handle perception (noisy, high-dimensional, statistical). The DBN handles causal fusion (structured, temporal, explainable). Neither is asked to do the other's job. This is why the GNN sits on the ~40-node cyber-physical asset graph (buses, lines, IEDs, RTUs, DERs, hosts) rather than the ~20-node attack graph — verified structurally in code: test_hops_from_der_bus_to_host_equals_n_gnn_layers confirms the chosen GNN depth (3 layers) is exactly the graph-distance from a DER bus to a compromised host, a claim that is only meaningful because the perception layer operates on the large, richly-typed asset graph.

Key equations

Reused exactly from the source paper — never approximated or reinvented:

Uniformization (converts a predicted mean time-to-compromise into a per-slice activation probability), for concurrent attack steps with mean completion times T̄ᵢ:

Δt  = 1 / ( m · Σᵢ (1 / T̄ᵢ) )
p_s = Δt / T̄_s

where m controls discretization accuracy — swept explicitly in experiments/exp10_m_sweep.py.

KL divergence (used throughout to compare inference strategies and, later, to compare the GNN's learned bus clustering against a heuristic):

D_KL(P‖Q) = Σₓ P(x) · log( P(x) / Q(x) )
M_KL      = max over time t ∈ [0,T] of D_KL at t

Three falsifiable claims

  • C1 — Closed loop. Making grid physics bidirectional — attack drives simulated instability, physical deviation returns as evidence — improves detection lead time and posterior calibration vs. the open-loop DBN.
  • C2 — Learned parameters. A GNN mapping (MITRE technique, asset context, defensive posture) → attack-step time-to-compromise, trained on digital-twin executions, matches or beats expert-elicited TTCs and transfers to unseen attack graphs.
  • C3 — Adversarial robustness (highest novelty). Under an RL attacker optimizing against the detector, the causal DBN degrades more gracefully than deep-IDS baselines, because structural preconditions can't be skipped — e.g. a spoofed reporting message can't be injected without first establishing MITM, and MITM requires credential access first.

Key results

Claim Finding Evidence
Paper reproduction FF-vs-EX M_KL (worst node) 3.5×10⁻² in Scenario 1 — the paper's own order (~2×10⁻²) — and 5.6×10⁻¹⁰ in Scenario 2; EX/FF per-slice latency 0.24 / 0.20 s (S1); 6 of 7 posterior checks pass (UnstablePS@t=31 is 0.81 vs. the text's 0.85, which the paper's own Fig. 7b shows as ≈0.81) exp01_reproduction_gate.png
C1 — closed loop Partial support (exp04 rerun 2026-10-10, current code). Closed loop is better calibrated (Brier 0.0017 vs. 0.0104) and far less late at high thresholds (θ = 0.99: −5.8 vs. −68.2 slices); for 0.15 ≤ θ < 0.71 the open loop leads (8.7 vs. ≤1.4 slices). At high θ both arms alarm after the instability — no early-warning win. Significance (30 paired scenarios, results/summary/stats_c1.csv): θ = 0.99 closed loop +62.4 slices less late [33.7, 95.3], better in 24/30, worse in 0, p < 10⁻⁴; Brier −0.0087 [−0.0148, −0.0038], p < 10⁻⁴; θ = 0.49 open loop leads by 9.9 slices [3.1, 18.9], p = 0.0004 claims_c1_c2_c3_summary.png
C2 — learned TTC amortized model, zero expert input, on 25 held-out test graphs: mean M_KL 0.226 vs. 0.296 (medians ≈0), detection 10/10 vs. 8/10 of the graphs with an event at θ=0.5 — almost all detections are at or after the instability. Not statistically significant (2026-10-16 analysis): mean difference −0.070, 95% CI [−0.195, +0.020], Wilcoxon p = 1.0 — four graphs carry the mean; detection 2 discordant graphs, McNemar p = 0.5 (results/summary/stats_c2.csv) exp08_ttc_fit_scatter.png
C3 — adversarial robustness Partial support. For θ < 0.71 the DBN detects every run that reaches instability and its mean lead does not shrink from blind to full-DBN attacker (7.9 → 15.2 slices) — but it is short: against the analytics-aware attacker it never alarms before the instability, and for θ ≥ 0.81 it misses all 25 of those runs. LSTM-AE has a longer lead at every level (23–136 slices); the rule-based IDS is evaded by the analytics-aware attacker for θ > 0.25 exp09_robustness_full_sweep.png
External baselines Baselines match or beat the DBN on raw AUC-PR (all ≥0.986), calibration (GBM, GNN ECE < 0.001 vs. DBN 0.005) and lead time (θ = 0.49 median: LSTM-AE 54, rule-based 44, DBN 0 slices). The DBN's remaining edge is causal explainability — stated plainly, not buried exp06_pr_curve.png
Real-data grounding Final detector (exp19): label-free, scenario-agnostic (IEC-104 traffic counts + physical event counts), fitted on two real networks and tested on the third. On the never-opened 03-Rural network: ROC-AUC 0.75, 2.5× AUC-PR lift, arp-spoof AUROC 0.94, every attack alarmed at least once, 7.8% false alarms. Industroyer and the 5% false-alarm target remain unmet — see Final real-data results exp19_universal_loso.png, exp17_19_pr_curves.png, exp19_03rural_timeline.png
GNN clustering vs. heuristic zoning (faculty-requested KL-divergence analysis) Unsupervised GNN clustering barely agrees with a hand-built heuristic zoning (Adjusted Rand Index = 0.09, a degenerate 31-vs-2 split). A zone-supervised auxiliary loss fixes the partition (ARI → 0.21; all 14 heuristic-labelled buses on the correct side) and, in the 2026-10-10 current-code rerun, removes the downstream KL entirely (M_KL 0 in all 30 scenarios). The earlier "zero effect" result came from an uncommitted tree and did not reproduce exp12_spatial_zone_map.png

Every number above traces to a logged experiment run stamped with a git SHA and random seed. Full hypothesis → result → interpretation record: LAB_NOTEBOOK.md (~4,000 lines). All 48 generated figures: results/figures/.

Final real-data results (Sherlock)

Real-data grounding took seven experiments (exp07, exp13–exp19); the full path, including every failed attempt, is in LAB_NOTEBOOK.md. The final state:

Final detector (exp19). 26 label-free features per 2-second bin — 23 IEC-104 traffic counts parsed from the raw switch packet captures, the count of IEC-104 control commands, the number of switch state changes, and the number of de-energised values from the physical snapshots. Each run is normalised against its own past (causal rolling robust z), so no clean data from the evaluated network is needed. PCA reconstruction error is the score; the alarm threshold adapts to the run's own recent scores. Every network is scored by a model fitted only on the other networks.

Network (model fitted on) Role ROC-AUC AUC-PR lift False alarms (adaptive) Recall at alarm
01-Basic (02) exploratory 0.79 4.5× 4.5% 54%
02-Semiurban (01) exploratory 0.72 2.3× 7.7% 46%
03-Rural (01 + 02), never opened before confirmatory 0.75 2.5× 7.8% 52%

Per attack family (mean per-event AUROC, 0.5 = chance):

Network arp-spoof control-and-freeze drift-off industroyer
01-Basic 0.97 0.88 0.77 0.70
02-Semiurban 0.94 0.69 0.73 0.64
03-Rural (unseen) 0.94 0.73 0.76 0.75

Pre-registered criteria on 03-Rural: overall detection (ROC ≥ 0.70, lift ≥ 2) met; arp-spoof ≥ 0.80 met; false alarms ≤ 5% missed (7.8%); industroyer ≥ 5 of 8 events at AUROC ≥ 0.9 missed (3 of 8).

How it got here — what moved the result:

Step Change Effect
exp13 11 aggregate state features, LSTM autoencoder 01: 2.9× lift; 02: ~chance (ROC 0.51)
exp14–16 full 12 h physical snapshots, 5,731 per-component columns, supervised variant no gain (ROC ≈ 0.44–0.51); one real finding: test runs sit in a different operating regime, so a clean-train detector false-alarms on 100% of normal slices
exp15 per-run causal baseline regime problem fixed (100% → 11% false alarms); industroyer 6/9 on 02
exp17 raw packet captures (own pcap parser, equal to tcpdump) the missing signal: arp-spoof 0.29 → 0.96; fused physical + network ROC 0.73 / 2.6× on 02
exp18 frozen exp17 pipeline on 01-Basic replicates (ROC 0.77, 3.7×); two pre-registered fixes failed
exp19 scenario-agnostic features, leave-one-network-out, adaptive threshold transfers to an unseen network (03-Rural ROC 0.75); false alarms 12–19% → 4.5–7.8%

Open limits: industroyer (breaker-opening) is detected reliably only on 02-Semiurban, by the per-component physical view (6/9 events) and its fusion with the network view (7/9); that view needs clean data from the same network and did not replicate on 01-Basic (1/7 fused); false alarms are above 5% on two of three networks; all three networks have now been used for testing, so no untouched Sherlock data remains for further tuning.

Verification (2026-10-09, updated 2026-10-10). verify_stack passes; 594 tests pass (rerun 2026-10-16); verify_reproducibility.py reproduces exp01 and exp03 bit-for-bit; the exp17 rerun reproduces every number exactly. A 2026-10-10 rerun of exp04 (C1) did NOT reproduce the original 2026-08-02 run (made from an uncommitted tree before the Session 5 twin changes); exp04 was re-pinned to the rerun and README numbers updated. The same day exp05, 06, 08, 09, 10, 11 and 12 were rerun with current code: exp08 (C2), exp09 (C3), exp10 and the exp12 baseline arm reproduce exactly; exp06 and exp11 drift only slightly (no cited number changes); exp05 and the exp12 zone-supervised arm changed (both earlier runs came from uncommitted trees) and were re-pinned. Perception-layer training was not bit-reproducible, so exp05's soft-evidence numbers vary between runs. The cause (node-type order depended on Python's per-process hash seed, changing the GNN's initial weights) was fixed on 2026-10-12 and is covered by a test; the pinned perception results predate the fix and have not been rerun. An earlier ±2-slice exp03 drift, previously blamed on BLAS non-determinism, was traced by bisection to two documented twin bug fixes (Sessions 4 and 5); the reference run was updated.

Experiment suite (exp01–exp19)

Each experiment is a standalone, runnable script under experiments/ that writes seeded, git-SHA-stamped CSVs to results/ and ends with a lettered structural validation gate (never a "does it look good" check — see Research integrity principles).

# Script Tests
01 exp01_reproduce_paper.py Reproduces the source paper's Scenarios 1 & 2 (KL, latency, memory) against its published reference table — the gate every later claim depends on
02 exp02_latched_kl.py Resolves an internal inconsistency found in the source paper's own published figures (memoryless vs. latched reaction assumptions)
03 exp03_twin_open_loop.py Feeds the digital twin's own (not hand-scripted) analytic firing times into the DBN, open-loop
04 exp04_closed_loop_c1.py Tests C1: closed- vs. open-loop lead time and calibration
05 exp05_perception.py Replaces the fixed p_pos=p_neg=10⁻⁴ evidence with a calibrated, learned likelihood from twin telemetry
06 exp06_baselines.py Four external ML baselines vs. the proposed system — AUC-PR, calibration, lead time, PR curves
07 exp07_sherlock.py Grounds the perception layer on real data (Sherlock); bidirectional twin↔real-data transfer
08 exp08_transfer_c2.py Tests C2: learned TTC transfer to 25 held-out, never-seen attack graphs
09 exp09_adversarial_c3.py Tests C3: PPO-trained RL attacker vs. every detector, three knowledge levels
10 exp10_m_sweep.py Sweeps the discretization parameter m from the uniformization equation
11 exp11_perception_ablation.py Isolates whether the GNN's graph structure — not just its parameter count — drives detection quality, vs. a per-asset MLP with zero message passing
12 exp12_gnn_cluster_vs_heuristic.py Faculty-requested: KL-divergence / ARI comparison between GNN-derived bus clustering and the heuristic zone map, plus a zone-supervised auxiliary-loss ablation
13 exp13_sherlock_full.py Uses all three real Sherlock scenarios: unsupervised anomaly detection trained on attack-free data, zero-shot cross-network transfer, and a supervised transfer matrix over the shared bus-voltage subspace; per-attack-type detectability from the dataset's own event catalog
14 exp14_sherlock_component.py 02-Semiurban's full 12 h physical snapshots (raw physical.zip): all 5,731 per-component columns instead of 11 aggregates, label-free PCA detectors; pre-registered criteria all missed (a clean-train detector saturates at 100% non-attack false alarms because the test run is in a different operating regime)
15 exp14_sherlock_component.py --config configs/sherlock_baseline.yaml Same, with every snapshot scored against the file's own causal trailing baseline: non-attack false alarms 100% → 11%, industroyer (breaker-opening) events 6/9 at AUROC ≥ 0.9; other families still at chance; overall ROC-AUC ≈ 0.50
16 exp16_sherlock_supervised.py Event-held-out supervised arm (gradient boosting on label-free PCA features; fixed hyperparameters): pooled out-of-fold ROC-AUC 0.44 — no learnable footprint found for arp-spoof / drift-off / control-and-freeze at this feature reduction
17 exp17_sherlock_network.py Parses the raw switch packet captures (own numpy pcap parser, verified against tcpdump) into 23 IEC-104 traffic counts on a uniform 2 s grid; label-free detector with causal baseline; also reruns the physical view on the uniform grid (cadence sensitivity) and fuses both: arp-spoof AUROC 0.96, fused ROC-AUC 0.73 / lift 2.6×, industroyer 7/9; control-and-freeze weak, fused false alarms 17%
18 exp17_sherlock_network.py --config configs/sherlock_network_exp18_01.yaml Out-of-sample replication of the frozen exp17 pipeline on 01-Basic (pooled ROC-AUC 0.77, lift 3.7×; industroyer did not replicate) plus two pre-registered fixes (mean fusion; decoded IEC-104 unchanged-measurement counts), both of which failed
19 exp19_sherlock_universal.py Scenario-agnostic detector, leave-one-scenario-out, self-calibrating threshold; confirmatory test on the never-opened 03-Rural: ROC-AUC 0.75, lift 2.5×, arp-spoof 0.94, false alarms 7.8% (4.5–7.8% across networks); industroyer still weak

Quickstart

python -m venv .venv && source .venv/bin/activate
pip install -e .
python scripts/verify_stack.py          # smoke-tests every dependency
pytest tests/ -q                        # 594 tests

Run any experiment — each writes seeded, git-SHA-stamped CSVs to results/:

python experiments/exp01_reproduce_paper.py     # paper reproduction gate
python experiments/exp04_closed_loop_c1.py      # closed-loop lead time (C1)
python experiments/exp09_adversarial_c3.py      # adversarial robustness (C3)

Real-data pipeline (needs the Sherlock download, see Data):

python scripts/build_sherlock_network_features.py --scenario 02-Semiurban   # pcap -> 2 s traffic bins
python experiments/exp17_sherlock_network.py                               # physical + network, 02-Semiurban
python experiments/exp19_sherlock_universal.py                             # final detector, leave-one-network-out

Most experiments accept --smoke for a fast, tiny-scale correctness run (structural gate only, explicitly not treated as a real result) before committing to the full multi-minute run:

python experiments/exp12_gnn_cluster_vs_heuristic.py --smoke

Rebuild every figure directly from already-logged results — no recomputation of any metric:

python scripts/build_summary_tables.py       # cross-experiment CSV consolidation
python scripts/generate_journal_plots.py     # per-experiment figures
python scripts/generate_publication_plots.py # threshold sweeps, spatial maps, claims summary

Interactive demo dashboard (Streamlit — see .claude/launch.json):

pip install -e ".[demo]"
streamlit run webapp/app.py

Module map

src/
├── attack_graph/        graph.py            NetworkX attack graph, MITRE technique mapping
│                         family.py            attack-graph-family generator for C2's transfer test
├── dbn/                  compiler.py          attack graph → 2TBN compiler
│                         parameterization.py  uniformization, CPT construction
│                         inference.py          FF / EX / BK forward-filtering inference engine
│                         soft_evidence.py      virtual/likelihood evidence entry
│                         forward_sample.py     forward sampling utilities
├── twin/                  grid.py              pandapower grid model, power-flow interface
│                         comms.py              SimPy control-center ↔ substation messaging
│                         attacker.py           scripted attacker agent, TTC-driven timing
│                         rl_attacker.py         Gym-compatible PPO adversarial-attacker env
│                         consequence.py         physical-deviation → evidence, ZoneMap heuristic
│                         runner.py              orchestrates twin end-to-end, discretization
├── perception/            asset_graph.py       heterogeneous cyber-physical asset graph builder
│                         encoder.py            HGT/RGCN heterogeneous GNN + temporal encoder
│                         features.py            dynamic feature construction from telemetry
│                         calibration.py         temperature scaling, ECE
│                         sherlock_loader.py      real Sherlock state export → feature schema
│                         sherlock_physical.py     Sherlock raw physical snapshots (aggregate + per-component views)
│                         sherlock_network.py      pure-numpy pcap parser → IEC-104 traffic counts per bin
│                         sherlock_grid.py         uniform-grid alignment (last observation carried forward)
│                         sherlock_anomaly.py      blocked splits, standardiser, autoencoder scoring helpers
│                         sherlock_component_detectors.py  causal robust z, PCA SPE / T² detectors
├── parameterization/      amortized.py         learned (technique, context) → TTC model (C2)
├── baselines/             lstm_ae.py, gbm.py, gnn_classifier.py, rule_based.py, common.py
└── eval/                  metrics.py            KL divergence, M_KL
                          lead_time.py          detection lead-time definition and sweep
                          calibration.py         ECE, Brier score, reliability diagrams
                          provenance.py          git SHA capture for every logged run

Research integrity principles

Every result in this repository is governed by five non-negotiable rules (the full contract is CLAUDE.md):

  1. Never fabricate, estimate, or hardcode a numerical result. If an experiment hasn't run, the value doesn't exist — code raises or writes NotImplemented, never a plausible-looking placeholder.
  2. Never write a table, plot, or summary from expected values. Every reported number traces to a logged run with a seed; a results table never appears before the experiment that produced it has run.
  3. A failed validation gate is reported, not adjusted to pass. No target is silently loosened, and a baseline beating the proposed system is reported prominently, not buried in a CSV column.
  4. Every experiment logs its git SHA, seed, config, and timestamp to a CSV under results/. No exceptions.
  5. Uncertainty is stated explicitly, including which parts of an implementation the author was least sure matched the source paper, and what was checked (not just asserted) before accepting a surprising result.

In practice: each experiment's own module docstring states its hypothesis and any open uncertainty before the code below it runs; each ends with a lettered gate ((a), (b), (c)...) testing structural correctness only — shapes, determinism, finiteness, provenance — never "does the number look right." The KL/ARI/AUC-PR numbers themselves always print unconditionally, whether or not they favor the proposed system.

Notable findings, including nulls

Negative and mixed results are kept and reported exactly like positive ones — a sample, in full detail in LAB_NOTEBOOK.md:

  • C1 is not a clean win, and its first headline did not reproduce. The original exp04 run (2026-08-02, uncommitted tree) reported +36 vs. −30 slices at θ = 0.99; a 2026-10-10 rerun with current code gives −5.8 vs. −68.2 — the closed loop is still far less late and better calibrated, but neither arm warns early at high θ. Below θ ≈ 0.71 the open loop leads. The canonical run was re-pinned and the change logged.
  • exp06's baselines match or beat the proposed system on raw AUC-PR, calibration and lead time. What the DBN still offers is causal explainability and a lead time that does not shrink as the attacker learns more (C3) — stated as such, not disguised as an accuracy win.
  • Learned soft evidence does not beat hard evidence in the DBN. In two current-code runs of exp05, the DBN posterior is best calibrated with hard evidence (ECE 0.0039) and worse with the perception layer's soft evidence (0.013–0.018), which also alarms later at high θ. Temperature scaling fixes CommandCoherence (ECE ≈0.09 → ≈0.02) but not MeasureCoherence (≈0.22–0.27); whether it lowers the DBN's ECE differs between runs. An earlier run from an uncommitted tree had shown a small calibration benefit; it did not reproduce. The run-to-run spread came from a hash-seed-dependent weight initialisation, fixed 2026-10-12 (not yet rerun).
  • Real data took seven attempts, and the physical state alone was not enough. exp07's "Sherlock→twin ≈ 0.99 AUC-PR" was uninformative (the twin's base rate is 0.9992). Every physical-state view tried (aggregates, 5,731 per-component columns, supervised) stayed near chance on 02-Semiurban; only the raw packet captures exposed arp-spoof, drift-off and control-and-freeze. The final detector transfers to an unseen network (ROC 0.75), but industroyer and the 5% false-alarm target remain open — see Final real-data results.
  • A reproducibility explanation was wrong and has been corrected. exp03's ±2-slice drift was attributed to BLAS non-determinism in 2026-08; single- threaded reruns and bisection showed the twin/DBN code is deterministic and the drift came from two documented twin bug fixes made after exp03's original run.
  • The real dataset has defects worth knowing. 02-Semiurban's shipped state export covers ~3 h of a 12 h run (7 of 29 attacks) and ends mid-line; 03-Rural's "train" file is actually its attack data.
  • The GNN-vs-heuristic clustering gap is real and diagnosed, not just measured. Root cause: unsupervised KMeans(k=2) on embeddings never trained for a clustering objective produces a degenerate 31-vs-2 split. A follow-up zone-supervised auxiliary loss fixes the partition (ARI 0.09 → 0.21, every heuristic-labelled bus on the correct side). The first run of that ablation (2026-08-14, uncommitted tree) reported zero effect on downstream KL; the 2026-10-10 rerun with current code instead gives M_KL = 0 in all 30 scenarios — zone supervision removes the gap. The earlier "clustering was not the bottleneck" conclusion is withdrawn.
  • A real float32-overflow bug was found and fixed during a full codebase debugging pass (src/baselines/lstm_ae.py, src/perception/calibration.py) — verified to have zero effect on previously published numbers by rerunning the affected experiments in full and diffing outputs bit-for-bit before and after the fix.

Data

Twin-generated data is synthetic and gitignored — nothing to download for the core pipeline. Real-world grounding uses Sherlock (Wagner et al., ACM CODASPY 2025), a real power-grid intrusion-detection dataset built on the Wattson co-simulator: three scenarios of increasing size (01-Basic, 02-Semiurban, 03-Rural; 470 / 3,565 / 1,894 state keys per record), with attack-free train splits for the first two and an attack-only 03-Rural for transfer. Every file the experiments read is used; see docs/sherlock_download.md for download steps (scripts/download_sherlock_parallel.py, resumable and integrity-checked) and every discrepancy found between the dataset's documented and actual structure — notably that 02-Semiurban's shipped state export covers ~3 of its 12 hours (7 of its 29 catalogued attacks) and that the state is what a network vantage point reconstructs from IEC-104 packets, not simulator truth. All members of all three scenarios are downloaded and CRC-32 verified (nested physical.zip / control-center.zip kept as zips). The final experiments read the raw physical snapshots and the raw switch packet captures (01-Basic: 4 captures per split, 02-Semiurban: 6, 03-Rural: 8 test-only). The dataset itself is never committed.

Tech stack

Library Role
pandapower Steady-state power-flow solver for the digital twin
pgmpy DBN structure and CPD representation
torch + torch-geometric Heterogeneous GNN (HGT / RGCN), temporal encoder
simpy Discrete-event attacker/comms simulation
networkx Attack-graph representation before DBN compilation
scikit-learn Calibration, PR curves, KMeans, Adjusted Rand Index
stable-baselines3 PPO for the adversarial RL attacker (C3)
pytest 594 tests, one per numerical component

Project structure

src/                    see Module map above
experiments/             exp01-exp19 (17 scripts; exp15 = exp14 + configs/sherlock_baseline.yaml, exp18 = exp17 + configs/sherlock_network_exp18_*.yaml)
results/                CSV outputs (git-SHA + seed logged), figures/, summary/
configs/                 YAML experiment configs — no magic numbers in source
tests/                   594 tests, one per numerical component
scripts/                 cross-experiment consolidation and figure-generation scripts
webapp/                  Streamlit demo dashboard
docs/                    Sherlock dataset download notes, literature review
LAB_NOTEBOOK.md         Hypothesis before, finding after — every experiment
CLAUDE.md                The project's living research-integrity contract

Background reading

docs/literature_review.md ties every architectural decision in this codebase back to a specific external source — journal papers, ACM/IEEE conference proceedings, arXiv preprints, GitHub repositories, and the MITRE ATT&CK for ICS knowledge base — stating what was reused unchanged, what was extended, and what gap it fills.

Citation

If referencing the source formalism this project extends:

@article{cerotti2025dbn,
  title   = {Dynamic {B}ayesian {N}etworks for the {D}etection and {A}nalysis
             of {C}yber {A}ttacks to {P}ower {S}ystems},
  author  = {Cerotti, Davide and Savarro, Davide and Codetta Raiteri, Daniele and
             Dondossola, Giovanna and Egidi, Lavinia and Franceschinis, Giuliana and
             Portinale, Luigi and Terruggia, Roberta},
  doi     = {10.1109/ACCESS.2025.3624345},
  journal = {IEEE Access},
  volume  = {13},
  pages   = {186289--186306},
  year    = {2025}
}

If referencing the real dataset used for grounding (Section Data):

@inproceedings{wagner2025sherlock,
  title     = {Sherlock: A Dataset for Process-aware Intrusion Detection
               Research on Power Grid Networks},
  author    = {Wagner, Eric and Bader, Lennart and Wolsing, Konrad and
               Serror, Martin},
  booktitle = {Proceedings of the 15th ACM Conference on Data and
               Application Security and Privacy (CODASPY)},
  year      = {2025}
}

Author: Sai Krishna Bodapati

About

Causal intrusion detection for smart power grids: a GNN perception layer feeding a Dynamic Bayesian Network over a pandapower physics loop, stress-tested against a trained PPO adversarial attacker. 530 tests, 12 pre-registered experiments.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages