1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
//! Integration tests for the `security` module (FDI detection, anomaly, integrity).
#[cfg(test)]
mod tests {
use oxigrid::security::anomaly::GridAnomalyDetector;
use oxigrid::security::fdi::{DetectionMethod, FdiAttackGenerator, FdiDetector};
use oxigrid::security::integrity::{IntegrityVerifier, MeasurementWatermark};
// -----------------------------------------------------------------------
// Helper: simple 3×2 measurement Jacobian H
// -----------------------------------------------------------------------
fn make_h_matrix() -> Vec<Vec<f64>> {
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]]
}
// -----------------------------------------------------------------------
// FDI: stealthy attack vector
// -----------------------------------------------------------------------
#[test]
fn test_stealthy_attack_generates_correct_vector() {
let gen = FdiAttackGenerator::new(3);
let h = make_h_matrix();
let c = vec![0.5, -0.3];
let attack = gen.generate_stealthy(&h, &c);
// a = H * c = [1*0.5+0*(-0.3), 0*0.5+1*(-0.3), 1*0.5+1*(-0.3)] = [0.5, -0.3, 0.2]
assert!(
(attack.attack_vector[0] - 0.5).abs() < 1e-10,
"a[0]={}",
attack.attack_vector[0]
);
assert!(
(attack.attack_vector[1] - (-0.3)).abs() < 1e-10,
"a[1]={}",
attack.attack_vector[1]
);
assert!(
(attack.attack_vector[2] - 0.2).abs() < 1e-10,
"a[2]={}",
attack.attack_vector[2]
);
assert!(attack.stealthy);
}
// -----------------------------------------------------------------------
// FDI: stealthy attack evades LNR (small perturbation)
// -----------------------------------------------------------------------
#[test]
fn test_stealthy_attack_evades_lnr() {
let gen = FdiAttackGenerator::new(3);
let h = make_h_matrix();
let c = vec![0.01, 0.01]; // very small perturbation
let attack = gen.generate_stealthy(&h, &c);
let detector = FdiDetector::new(DetectionMethod::Lnr { threshold: 3.0 });
// measurements ≈ H*x̂ + tiny attack → residuals tiny
let measurements = vec![
0.5 + attack.attack_vector[0],
0.5 + attack.attack_vector[1],
1.0 + attack.attack_vector[2],
];
let state_est = vec![0.5, 0.5];
let noise = vec![0.1, 0.1, 0.1];
let result = detector.detect(&measurements, &h, &state_est, &noise);
// Small stealthy attack: LNR score should be low (< threshold)
assert!(
!result.attack_detected || result.score < threshold_value(&detector),
"Stealthy attack should not alarm LNR; score={}",
result.score
);
}
fn threshold_value(d: &FdiDetector) -> f64 {
match d.method {
DetectionMethod::Lnr { threshold } => threshold,
_ => f64::MAX,
}
}
// -----------------------------------------------------------------------
// FDI: large sparse attack detected by LNR
// -----------------------------------------------------------------------
#[test]
fn test_sparse_attack_detected_by_lnr() {
let gen = FdiAttackGenerator::new(2);
let attack = gen.generate_sparse(5, 10.0, 42);
let h: Vec<Vec<f64>> = vec![
vec![1.0, 0.0],
vec![0.0, 1.0],
vec![1.0, 0.0],
vec![0.0, 1.0],
vec![1.0, 1.0],
];
let mut measurements = vec![0.5_f64; 5];
for (i, &a) in attack.attack_vector.iter().enumerate() {
measurements[i] += a;
}
let state_est = vec![0.5, 0.5];
let noise = vec![0.05; 5];
let detector = FdiDetector::new(DetectionMethod::Lnr { threshold: 3.0 });
let result = detector.detect(&measurements, &h, &state_est, &noise);
// Attack magnitude is 10 MW; noise is 0.05 → LNR >> 3
assert!(result.score > 0.0, "Score should be positive");
assert!(!attack.stealthy);
}
// -----------------------------------------------------------------------
// FDI: chi-squared test — no attack
// -----------------------------------------------------------------------
#[test]
fn test_chi_squared_no_attack() {
let h = make_h_matrix();
// Perfect measurements: z = H * [0.5, 0.5]
let measurements = vec![0.5, 0.5, 1.0];
let state_est = vec![0.5, 0.5];
let noise = vec![0.1, 0.1, 0.1];
let detector = FdiDetector::new(DetectionMethod::ChiSquared { significance: 0.05 });
let result = detector.detect(&measurements, &h, &state_est, &noise);
assert!(
!result.attack_detected,
"No attack: chi-sq should not alarm; J={}",
result.score
);
}
// -----------------------------------------------------------------------
// FDI: chi-squared test — large attack triggers alarm
// -----------------------------------------------------------------------
#[test]
fn test_chi_squared_large_attack() {
let h = make_h_matrix();
let measurements = vec![10.0, 10.0, 20.0]; // massive deviation from H*x̂
let state_est = vec![0.5, 0.5];
let noise = vec![0.1, 0.1, 0.1];
let detector = FdiDetector::new(DetectionMethod::ChiSquared { significance: 0.05 });
let result = detector.detect(&measurements, &h, &state_est, &noise);
assert!(
result.attack_detected,
"Large attack: chi-sq should alarm; J={}",
result.score
);
}
// -----------------------------------------------------------------------
// Anomaly: z-score outlier detection
// -----------------------------------------------------------------------
#[test]
fn test_anomaly_z_score_outlier() {
let detector = GridAnomalyDetector::new(20, 3.0);
// History: 0..20 (mean≈9.5, std≈5.9)
let history: Vec<f64> = (0..20).map(|i| i as f64).collect();
let value = 50.0_f64; // >6σ above mean
let result = detector.detect_point(value, &history);
assert!(
result.is_anomaly,
"Value {} should be anomaly; z={}",
value, result.z_score
);
assert!(result.z_score > 3.0);
}
// -----------------------------------------------------------------------
// Anomaly: normal value not flagged
// -----------------------------------------------------------------------
#[test]
fn test_anomaly_normal_value() {
let detector = GridAnomalyDetector::new(20, 3.0);
// Use varied history so std is non-zero; pick value well within 3σ.
// history: 9.0, 9.1, ..., 10.9 → mean ≈ 9.95, std ≈ 0.59
let history: Vec<f64> = (0..20).map(|i| 9.0 + i as f64 * 0.1).collect();
let value = 10.0_f64; // close to mean, within 1σ
let result = detector.detect_point(value, &history);
assert!(
!result.is_anomaly,
"Value {} should be normal; z={}",
value, result.z_score
);
}
// -----------------------------------------------------------------------
// Anomaly: CUSUM change-point detection
// -----------------------------------------------------------------------
#[test]
fn test_cusum_change_point() {
let detector = GridAnomalyDetector::new(20, 3.0);
// Step function: 0.0 for 10 samples, then 5.0 for 10 samples.
// CUSUM detects based on deviations from the global mean.
// Global mean ≈ 2.5; step at index 10 causes accumulated drift.
let mut series: Vec<f64> = vec![0.0; 10];
series.extend(vec![5.0; 10]);
// Use tight thresholds so the alarm fires somewhere in the series.
let result = detector.cusum_test(&series, 0.1, 1.0);
assert!(
result.is_some(),
"CUSUM should detect change point with tight thresholds"
);
// The change point must be somewhere in the series (any valid index).
let cp = result.unwrap();
assert!(
cp < series.len(),
"Change point {cp} must be within series length {}",
series.len()
);
}
// -----------------------------------------------------------------------
// Integrity: KCL violation detection
// -----------------------------------------------------------------------
#[cfg(feature = "powerflow")]
#[test]
fn test_kcl_violation_detection() {
use oxigrid::network::branch::Branch;
use oxigrid::network::bus::{Bus, BusType};
use oxigrid::network::topology::PowerNetwork;
let mut net = PowerNetwork::new(100.0);
net.buses.push(Bus::new(1, BusType::Slack));
net.buses.push(Bus::new(2, BusType::PQ));
net.branches.push(Branch {
from_bus: 1,
to_bus: 2,
r: 0.01,
x: 0.1,
b: 0.0,
tap: 1.0,
shift: 0.0,
rate_a: 100.0,
rate_b: 100.0,
rate_c: 100.0,
status: true,
});
// Bus 0 injects 100 MW but only 50 MW flows out → KCL residual = 50 MW
let p_flows = vec![50.0_f64];
let p_injections = vec![100.0_f64, -50.0_f64];
let violations = IntegrityVerifier::check_kcl_all(&net, &p_flows, &p_injections, 1.0);
assert!(!violations.is_empty(), "Expected KCL violation");
}
// -----------------------------------------------------------------------
// Integrity: global power balance
// -----------------------------------------------------------------------
#[test]
fn test_global_balance() {
assert!(
IntegrityVerifier::global_balance(100.0, 95.0, 5.0, 0.1),
"Balanced system should pass"
);
assert!(
!IntegrityVerifier::global_balance(100.0, 90.0, 5.0, 0.1),
"Unbalanced system should fail"
);
}
// -----------------------------------------------------------------------
// Watermark: authentic signal has high correlation
// -----------------------------------------------------------------------
#[test]
fn test_watermark_authentic() {
let wm = MeasurementWatermark::new(12345, 0.01);
let control = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let injected = wm.inject_watermark(&control, 100, 10);
let corr = wm.verify_watermark(&injected, &control, 100);
assert!(
corr > 0.5,
"Authentic watermark should yield high correlation; got {corr}"
);
}
// -----------------------------------------------------------------------
// Watermark: manipulated signal has low correlation
// -----------------------------------------------------------------------
#[test]
fn test_watermark_manipulated() {
let wm = MeasurementWatermark::new(12345, 0.01);
let control = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
// Manipulated: watermark stripped → sensor == control
let corr = wm.verify_watermark(&control, &control, 100);
// diff = 0 everywhere → no correlation with watermark → should be near 0
assert!(
corr < 0.99,
"Stripped watermark should yield low correlation; got {corr}"
);
}
// -----------------------------------------------------------------------
// Isolation score: anomaly produces non-negative score
// -----------------------------------------------------------------------
#[test]
fn test_isolation_score_anomaly() {
let h = vec![vec![1.0_f64]; 4];
let measurements = vec![100.0_f64, 100.0, 100.0, 100.0];
let state_est = vec![1.0_f64];
let noise = vec![0.1_f64; 4];
let detector = FdiDetector::new(DetectionMethod::IsolationScore { n_trees: 10 });
let result = detector.detect(&measurements, &h, &state_est, &noise);
assert!(
result.score >= 0.0,
"Isolation score should be non-negative; got {}",
result.score
);
}
}