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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
//! Adaptive microgrid protection system.
//!
//! Microgrids present unique protection challenges because fault current levels
//! change dramatically between grid-connected and islanded modes. This module
//! implements an adaptive protection scheme that automatically adjusts relay
//! settings based on the current operating mode.
//!
//! # Key Concepts
//!
//! - **Grid-connected mode**: fault currents of 10–20× rated (utility contribution)
//! - **Islanded mode**: inverter-limited fault currents of 1.5–3× rated
//! - **ROCOF relay**: detects islanding via rate-of-change of frequency \[Hz/s\]
//! - **Vector shift relay**: detects phase jump on loss-of-grid
//! - **Adaptive relay**: dual-setting relay that switches settings on mode change
use serde::{Deserialize, Serialize};
use std::fmt;
/// Error type for microgrid protection operations.
#[derive(Debug, Clone, PartialEq)]
pub enum ProtectionError {
/// No relays configured for the system.
NoRelaysConfigured,
/// Invalid relay configuration (e.g., zero pickup current).
InvalidRelayConfig(String),
/// Fault bus index out of range.
InvalidBus(usize),
/// Numerical computation error.
ComputationError(String),
}
impl fmt::Display for ProtectionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoRelaysConfigured => write!(f, "No relays configured"),
Self::InvalidRelayConfig(msg) => write!(f, "Invalid relay config: {}", msg),
Self::InvalidBus(b) => write!(f, "Invalid bus index: {}", b),
Self::ComputationError(msg) => write!(f, "Computation error: {}", msg),
}
}
}
impl std::error::Error for ProtectionError {}
/// Operating mode of the microgrid.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum MicrogridMode {
/// Connected to the main utility grid; high fault current levels.
GridConnected,
/// Islanded (disconnected from grid); inverter-limited fault currents.
Islanded,
/// Transitioning between modes; relay settings being adapted.
Transitioning,
}
impl fmt::Display for MicrogridMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GridConnected => write!(f, "GridConnected"),
Self::Islanded => write!(f, "Islanded"),
Self::Transitioning => write!(f, "Transitioning"),
}
}
}
/// Configuration parameters for the microgrid protection system.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MicrogridProtectionConfig {
/// Nominal system frequency \[Hz\].
pub nominal_frequency_hz: f64,
/// Typical fault current in grid-connected mode \[kA\] (10–20× rated).
pub grid_connected_fault_current_ka: f64,
/// Typical fault current in islanded mode \[kA\] (1.5–3× rated, inverter-limited).
pub islanded_fault_current_ka: f64,
/// Time to detect operating mode change \[ms\].
pub mode_detection_time_ms: f64,
/// Coordination time margin between protection zones \[ms\].
pub protection_coordination_margin_ms: f64,
}
impl Default for MicrogridProtectionConfig {
fn default() -> Self {
Self {
nominal_frequency_hz: 50.0,
grid_connected_fault_current_ka: 5.0,
islanded_fault_current_ka: 0.8,
mode_detection_time_ms: 100.0,
protection_coordination_margin_ms: 200.0,
}
}
}
/// Type of adaptive protective relay.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum AdaptiveRelayType {
/// Directional overcurrent relay (67-type).
DirectionalOvercurrent,
/// Differential protection relay (87-type).
Differential,
/// Under-frequency relay (81U-type) for load shedding.
UnderFrequency,
/// Over-voltage relay (59-type).
OverVoltage,
/// Rate-Of-Change-Of-Frequency relay (81R/ROCOF) for islanding detection.
RateOfChangeOfFrequency,
/// Vector shift relay (78V) for voltage phase jump detection.
VectorShift,
}
/// Relay operating setting (pickup, time dial, trip delay, direction).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelaySetting {
/// Pickup current threshold \[kA\].
pub pickup_current_ka: f64,
/// Time dial setting for IDMT curve (dimensionless).
pub time_dial: f64,
/// Definite-time trip delay \[ms\].
pub trip_delay_ms: f64,
/// Whether relay has directional element enabled.
pub directional: bool,
}
impl RelaySetting {
/// Create a new relay setting.
pub fn new(
pickup_current_ka: f64,
time_dial: f64,
trip_delay_ms: f64,
directional: bool,
) -> Self {
Self {
pickup_current_ka,
time_dial,
trip_delay_ms,
directional,
}
}
}
/// Adaptive relay with dual settings for grid-connected and islanded modes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdaptiveRelay {
/// Unique relay identifier.
pub relay_id: usize,
/// Bus to which this relay is connected.
pub bus: usize,
/// Relay type (directional overcurrent, differential, ROCOF, etc.).
pub relay_type: AdaptiveRelayType,
/// Settings used in grid-connected mode (higher pickup for large fault currents).
pub grid_connected_setting: RelaySetting,
/// Settings used in islanded mode (lower pickup for inverter-limited currents).
pub islanded_setting: RelaySetting,
/// Currently active setting (switches on mode change).
pub current_setting: RelaySetting,
}
impl AdaptiveRelay {
/// Create a new adaptive relay.
pub fn new(
relay_id: usize,
bus: usize,
relay_type: AdaptiveRelayType,
grid_connected_setting: RelaySetting,
islanded_setting: RelaySetting,
) -> Self {
let current_setting = grid_connected_setting.clone();
Self {
relay_id,
bus,
relay_type,
grid_connected_setting,
islanded_setting,
current_setting,
}
}
/// Update the active setting based on the current mode.
pub fn apply_mode(&mut self, mode: MicrogridMode) {
match mode {
MicrogridMode::GridConnected => {
self.current_setting = self.grid_connected_setting.clone();
}
MicrogridMode::Islanded => {
self.current_setting = self.islanded_setting.clone();
}
MicrogridMode::Transitioning => {
// During transition, use the more sensitive (islanded) setting
// for safety — prefer false trips over missed faults.
self.current_setting = self.islanded_setting.clone();
}
}
}
/// Compute IDMT operating time using IEC 60255 Standard Inverse curve \[ms\].
///
/// Formula: `t = 0.14 / ((I/Ip)^0.02 − 1) × TMS × 1000`
///
/// Returns `None` if the current is below pickup.
pub fn idmt_trip_time_ms(&self, fault_current_ka: f64) -> Option<f64> {
let i_ratio = fault_current_ka / self.current_setting.pickup_current_ka;
if i_ratio <= 1.0 {
return None;
}
// IEC 60255 Standard Inverse
let t_s = 0.14 / (i_ratio.powf(0.02) - 1.0) * self.current_setting.time_dial;
// Return max of IDMT time and definite-time delay
let t_ms = t_s * 1000.0_f64.max(self.current_setting.trip_delay_ms);
Some(t_ms.max(self.current_setting.trip_delay_ms))
}
/// Check if relay operates for given fault current.
pub fn operates(&self, fault_current_ka: f64) -> bool {
fault_current_ka > self.current_setting.pickup_current_ka
}
/// Get trip time in \[ms\] for the given fault current.
pub fn trip_time_ms(&self, fault_current_ka: f64) -> Option<f64> {
if !self.operates(fault_current_ka) {
return None;
}
match self.relay_type {
AdaptiveRelayType::DirectionalOvercurrent => self.idmt_trip_time_ms(fault_current_ka),
_ => {
// For non-overcurrent relays, use the definite-time setting
Some(self.current_setting.trip_delay_ms)
}
}
}
}
/// Islanding detection method.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum IslandingDetectionMethod {
/// ROCOF relay (81R): monitors df/dt \[Hz/s\].
RocofRelay,
/// Vector shift relay: monitors voltage phase angle jump \[°\].
VectorShift,
/// Over/under-frequency protection (81O/81U).
OverUnderFrequency,
/// Over/under-voltage protection (59/27).
OverUnderVoltage,
/// Hybrid: combines ROCOF, vector shift, frequency, and voltage methods.
Hybrid,
}
/// Configuration for the islanding detection subsystem.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IslandingDetection {
/// Primary detection method.
pub method: IslandingDetectionMethod,
/// ROCOF trip threshold \[Hz/s\] (typical 0.5–2.0).
pub rocof_threshold_hz_per_s: f64,
/// Vector shift trip threshold \[°\] (typical 6–12°).
pub vector_shift_threshold_deg: f64,
/// Under-frequency trip threshold \[Hz\].
pub under_freq_hz: f64,
/// Over-frequency trip threshold \[Hz\].
pub over_freq_hz: f64,
/// Under-voltage trip threshold \[pu\].
pub under_voltage_pu: f64,
/// Over-voltage trip threshold \[pu\].
pub over_voltage_pu: f64,
/// Expected detection time \[ms\].
pub detection_time_ms: f64,
}
impl Default for IslandingDetection {
fn default() -> Self {
Self {
method: IslandingDetectionMethod::Hybrid,
rocof_threshold_hz_per_s: 1.0,
vector_shift_threshold_deg: 10.0,
under_freq_hz: 49.0,
over_freq_hz: 51.0,
under_voltage_pu: 0.85,
over_voltage_pu: 1.10,
detection_time_ms: 160.0,
}
}
}
impl IslandingDetection {
/// Evaluate whether islanding is detected given post-fault measurements.
///
/// Returns `(detected, detection_time_ms)`.
pub fn evaluate(
&self,
pre_freq_hz: f64,
post_freq_hz: f64,
post_voltage_pu: f64,
voltage_phase_jump_deg: f64,
nominal_freq_hz: f64,
) -> (bool, f64) {
let dt_s = self.detection_time_ms / 1000.0;
let rocof = (post_freq_hz - pre_freq_hz).abs() / dt_s;
let rocof_trip = rocof > self.rocof_threshold_hz_per_s;
let vs_trip = voltage_phase_jump_deg.abs() > self.vector_shift_threshold_deg;
let under_f = post_freq_hz < self.under_freq_hz;
let over_f = post_freq_hz > self.over_freq_hz;
let under_v = post_voltage_pu < self.under_voltage_pu;
let over_v = post_voltage_pu > self.over_voltage_pu;
let freq_ok = (post_freq_hz - nominal_freq_hz).abs() < 2.0;
let _ = freq_ok;
let detected = match self.method {
IslandingDetectionMethod::RocofRelay => rocof_trip,
IslandingDetectionMethod::VectorShift => vs_trip,
IslandingDetectionMethod::OverUnderFrequency => under_f || over_f,
IslandingDetectionMethod::OverUnderVoltage => under_v || over_v,
IslandingDetectionMethod::Hybrid => {
// Hybrid: any single method triggers
rocof_trip || vs_trip || under_f || over_f || under_v || over_v
}
};
let detection_time = if detected {
self.detection_time_ms
} else {
f64::INFINITY
};
(detected, detection_time)
}
}
/// Result of a microgrid protection simulation event.
#[derive(Debug, Clone)]
pub struct MicrogridProtectionResult {
/// Current operating mode detected.
pub mode: MicrogridMode,
/// Whether islanding was detected.
pub islanding_detected: bool,
/// Time to detect islanding \[ms\]; `None` if not detected.
pub islanding_detection_time_ms: Option<f64>,
/// Whether a fault was detected by any relay.
pub fault_detected: bool,
/// Fault location (bus index); `None` if not identified.
pub fault_location: Option<usize>,
/// Trip sequence: `(relay_id, trip_time_ms)` in chronological order.
pub trip_sequence: Vec<(usize, f64)>,
/// Whether protection coordination is satisfied (no selectivity violations).
pub protection_coordination_ok: bool,
/// List of selectivity violations (upstream clears before downstream).
pub selectivity_violations: Vec<String>,
}
/// Adaptive microgrid protection system.
///
/// Manages a fleet of adaptive relays and an islanding detection subsystem.
/// Relay settings are automatically switched when the operating mode changes.
pub struct MicrogridProtectionSystem {
config: MicrogridProtectionConfig,
relays: Vec<AdaptiveRelay>,
islanding_detector: IslandingDetection,
current_mode: MicrogridMode,
}
impl MicrogridProtectionSystem {
/// Create a new microgrid protection system.
pub fn new(config: MicrogridProtectionConfig, islanding_detector: IslandingDetection) -> Self {
Self {
config,
relays: Vec::new(),
islanding_detector,
current_mode: MicrogridMode::GridConnected,
}
}
/// Add a relay to the protection system.
pub fn add_relay(&mut self, relay: AdaptiveRelay) {
self.relays.push(relay);
}
/// Update the operating mode and adapt all relay settings.
pub fn update_mode(&mut self, mode: MicrogridMode) {
self.current_mode = mode;
for relay in &mut self.relays {
relay.apply_mode(mode);
}
}
/// Get current operating mode.
pub fn current_mode(&self) -> MicrogridMode {
self.current_mode
}
/// Simulate a fault event and determine the protection response.
///
/// # Parameters
///
/// - `fault_bus`: bus index where the fault occurs
/// - `fault_current_ka`: fault current magnitude \[kA\]
/// - `pre_fault_voltage_pu`: pre-fault voltage \[pu\]
/// - `pre_fault_frequency_hz`: pre-fault frequency \[Hz\]
/// - `post_fault_frequency_hz`: post-fault frequency \[Hz\]
/// - `post_fault_voltage_pu`: post-fault voltage \[pu\]
pub fn simulate_fault(
&self,
fault_bus: usize,
fault_current_ka: f64,
pre_fault_voltage_pu: f64,
pre_fault_frequency_hz: f64,
post_fault_frequency_hz: f64,
post_fault_voltage_pu: f64,
) -> Result<MicrogridProtectionResult, ProtectionError> {
if self.relays.is_empty() {
return Err(ProtectionError::NoRelaysConfigured);
}
// Compute voltage phase jump (simplified: proportional to voltage drop)
let voltage_drop = (pre_fault_voltage_pu - post_fault_voltage_pu).abs();
let phase_jump_deg = voltage_drop * 30.0; // heuristic: 1 pu drop ≈ 30° shift
// Check islanding detection
let (islanding_detected, islanding_time_ms) = self.islanding_detector.evaluate(
pre_fault_frequency_hz,
post_fault_frequency_hz,
post_fault_voltage_pu,
phase_jump_deg,
self.config.nominal_frequency_hz,
);
// Determine which relays operate and their trip times
let mut trip_sequence: Vec<(usize, f64)> = Vec::new();
let mut fault_detected = false;
for relay in &self.relays {
if let Some(trip_time) = relay.trip_time_ms(fault_current_ka) {
fault_detected = true;
trip_sequence.push((relay.relay_id, trip_time));
}
}
// Sort by trip time (earliest first)
trip_sequence.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
// Determine fault location
let fault_location = if fault_detected {
Some(fault_bus)
} else {
None
};
// Check coordination
let violations = self.check_coordination();
let protection_coordination_ok = violations.is_empty();
Ok(MicrogridProtectionResult {
mode: self.current_mode,
islanding_detected,
islanding_detection_time_ms: if islanding_detected {
Some(islanding_time_ms)
} else {
None
},
fault_detected,
fault_location,
trip_sequence,
protection_coordination_ok,
selectivity_violations: violations,
})
}
/// Check protection coordination: verify downstream relay trips before upstream.
///
/// Returns a list of violation descriptions. An empty list means coordination is OK.
pub fn check_coordination(&self) -> Vec<String> {
let mut violations = Vec::new();
let margin_ms = self.config.protection_coordination_margin_ms;
// Use a representative test current (grid-connected fault level)
let test_current = self.config.grid_connected_fault_current_ka * 0.5;
// Collect trip times for all overcurrent relays
let oc_relays: Vec<&AdaptiveRelay> = self
.relays
.iter()
.filter(|r| r.relay_type == AdaptiveRelayType::DirectionalOvercurrent)
.collect();
// Check every pair: relays at lower bus_id are assumed downstream of higher bus_id
// (simplified model: lower index = closer to load)
for i in 0..oc_relays.len() {
for j in (i + 1)..oc_relays.len() {
let upstream = oc_relays[j]; // higher index = closer to source
let downstream = oc_relays[i]; // lower index = closer to load
if upstream.bus <= downstream.bus {
continue; // skip if ordering doesn't indicate upstream/downstream
}
let t_down = downstream.trip_time_ms(test_current);
let t_up = upstream.trip_time_ms(test_current);
match (t_down, t_up) {
(Some(td), Some(tu)) if tu <= td + margin_ms => {
violations.push(format!(
"Coordination violation: relay {} (upstream, bus {}) trips at {:.1} ms, \
relay {} (downstream, bus {}) trips at {:.1} ms — margin {:.1} ms < required {:.1} ms",
upstream.relay_id, upstream.bus, tu,
downstream.relay_id, downstream.bus, td,
tu - td, margin_ms
));
}
(None, Some(_)) => {
// Upstream operates but downstream doesn't — possible mis-coordination
violations.push(format!(
"Selectivity issue: upstream relay {} (bus {}) operates but \
downstream relay {} (bus {}) does not",
upstream.relay_id, upstream.bus, downstream.relay_id, downstream.bus
));
}
_ => {}
}
}
}
violations
}
/// Number of configured relays.
pub fn relay_count(&self) -> usize {
self.relays.len()
}
/// Access relays by index.
pub fn relay(&self, idx: usize) -> Option<&AdaptiveRelay> {
self.relays.get(idx)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_system() -> MicrogridProtectionSystem {
let config = MicrogridProtectionConfig {
nominal_frequency_hz: 50.0,
grid_connected_fault_current_ka: 5.0,
islanded_fault_current_ka: 0.8,
mode_detection_time_ms: 100.0,
protection_coordination_margin_ms: 200.0,
};
let islanding = IslandingDetection {
method: IslandingDetectionMethod::Hybrid,
rocof_threshold_hz_per_s: 1.0,
vector_shift_threshold_deg: 10.0,
under_freq_hz: 49.0,
over_freq_hz: 51.0,
under_voltage_pu: 0.85,
over_voltage_pu: 1.10,
detection_time_ms: 160.0,
};
MicrogridProtectionSystem::new(config, islanding)
}
fn add_standard_relays(sys: &mut MicrogridProtectionSystem) {
// Downstream relay (bus 1) — lower pickup, faster time dial
let relay_down = AdaptiveRelay::new(
1,
1,
AdaptiveRelayType::DirectionalOvercurrent,
RelaySetting::new(0.5, 0.1, 100.0, true),
RelaySetting::new(0.1, 0.05, 60.0, true),
);
// Upstream relay (bus 5) — higher pickup, slower time dial
let relay_up = AdaptiveRelay::new(
2,
5,
AdaptiveRelayType::DirectionalOvercurrent,
RelaySetting::new(1.0, 0.3, 300.0, true),
RelaySetting::new(0.3, 0.15, 200.0, true),
);
sys.add_relay(relay_down);
sys.add_relay(relay_up);
}
#[test]
fn test_grid_connected_fault_trip_sequence() {
let mut sys = make_system();
add_standard_relays(&mut sys);
let result = sys
.simulate_fault(1, 3.0, 1.0, 50.0, 49.5, 0.90)
.expect("simulate_fault should succeed");
assert!(result.fault_detected, "Fault should be detected at 3.0 kA");
assert!(
!result.trip_sequence.is_empty(),
"Trip sequence should not be empty"
);
// Downstream (relay 1) should trip before upstream (relay 2)
let first_trip_id = result.trip_sequence[0].0;
assert_eq!(first_trip_id, 1, "Downstream relay should trip first");
}
#[test]
fn test_islanded_fault_uses_sensitive_settings() {
let mut sys = make_system();
add_standard_relays(&mut sys);
sys.update_mode(MicrogridMode::Islanded);
// Low current typical of islanded inverter-limited fault
let result = sys
.simulate_fault(1, 0.5, 1.0, 50.0, 49.0, 0.88)
.expect("simulate_fault should succeed");
// In islanded mode, pickup for relay 1 is 0.1 kA — should detect 0.5 kA fault
assert!(
result.fault_detected,
"Islanded fault should be detected with sensitive settings"
);
assert_eq!(result.mode, MicrogridMode::Islanded);
}
#[test]
fn test_islanding_detection_rocof() {
let detector = IslandingDetection {
method: IslandingDetectionMethod::RocofRelay,
rocof_threshold_hz_per_s: 1.0,
detection_time_ms: 100.0,
..IslandingDetection::default()
};
// Large frequency deviation → high ROCOF
let (detected, time_ms) = detector.evaluate(50.0, 48.5, 0.95, 5.0, 50.0);
assert!(detected, "ROCOF should detect large frequency deviation");
assert!(time_ms < f64::INFINITY, "Detection time should be finite");
// Small deviation → no ROCOF trip
let (not_detected, _) = detector.evaluate(50.0, 50.02, 1.0, 0.5, 50.0);
assert!(!not_detected, "Small ROCOF should not trigger");
}
#[test]
fn test_islanding_detection_vector_shift() {
let detector = IslandingDetection {
method: IslandingDetectionMethod::VectorShift,
vector_shift_threshold_deg: 10.0,
detection_time_ms: 80.0,
..IslandingDetection::default()
};
// 20° phase jump → detected
let (detected, _) = detector.evaluate(50.0, 50.0, 0.7, 20.0, 50.0);
assert!(detected, "Large vector shift should be detected");
// 5° phase jump → not detected
let (not_detected, _) = detector.evaluate(50.0, 50.0, 1.0, 5.0, 50.0);
assert!(!not_detected, "Small vector shift should not trigger");
}
#[test]
fn test_mode_transition_updates_relay_settings() {
let mut sys = make_system();
add_standard_relays(&mut sys);
// Initially grid-connected: relay 0 pickup = 0.5 kA
assert!((sys.relays[0].current_setting.pickup_current_ka - 0.5).abs() < 1e-9);
// Switch to islanded: pickup should drop to 0.1 kA
sys.update_mode(MicrogridMode::Islanded);
assert!(
(sys.relays[0].current_setting.pickup_current_ka - 0.1).abs() < 1e-9,
"Relay pickup should switch to islanded value"
);
// Switch back: pickup returns to 0.5 kA
sys.update_mode(MicrogridMode::GridConnected);
assert!((sys.relays[0].current_setting.pickup_current_ka - 0.5).abs() < 1e-9);
}
#[test]
fn test_coordination_check_detects_violation() {
let config = MicrogridProtectionConfig {
nominal_frequency_hz: 50.0,
grid_connected_fault_current_ka: 5.0,
islanded_fault_current_ka: 0.8,
mode_detection_time_ms: 100.0,
protection_coordination_margin_ms: 400.0, // large margin → easy to violate
};
let mut sys = MicrogridProtectionSystem::new(config, IslandingDetection::default());
// Both relays with nearly identical settings → coordination violation
let relay1 = AdaptiveRelay::new(
1,
1,
AdaptiveRelayType::DirectionalOvercurrent,
RelaySetting::new(0.5, 0.1, 100.0, true),
RelaySetting::new(0.1, 0.05, 60.0, true),
);
let relay2 = AdaptiveRelay::new(
2,
5,
AdaptiveRelayType::DirectionalOvercurrent,
RelaySetting::new(0.6, 0.12, 150.0, true), // barely different
RelaySetting::new(0.15, 0.07, 100.0, true),
);
sys.add_relay(relay1);
sys.add_relay(relay2);
let violations = sys.check_coordination();
assert!(
!violations.is_empty(),
"Should detect coordination violation with 400 ms required margin"
);
}
#[test]
fn test_coordination_check_ok_with_proper_settings() {
let mut sys = make_system(); // 200 ms margin
add_standard_relays(&mut sys);
// With upstream trip_delay=300ms and downstream trip_delay=100ms,
// margin = 200ms which equals the required margin. The check uses test_current
// to evaluate IDMT so result depends on actual IDMT values.
// We just verify the function runs and returns a Vec<String>.
let violations = sys.check_coordination();
// violations may or may not be empty; key check is that the function doesn't panic
let _ = violations;
}
#[test]
fn test_hybrid_islanding_detects_undervoltage() {
let detector = IslandingDetection {
method: IslandingDetectionMethod::Hybrid,
under_voltage_pu: 0.85,
detection_time_ms: 160.0,
..IslandingDetection::default()
};
// Voltage drops below threshold
let (detected, time_ms) = detector.evaluate(50.0, 50.0, 0.80, 2.0, 50.0);
assert!(detected, "Hybrid should detect undervoltage islanding");
assert!((time_ms - 160.0).abs() < 1.0);
}
#[test]
fn test_no_relays_returns_error() {
let sys = make_system();
let result = sys.simulate_fault(0, 1.0, 1.0, 50.0, 49.5, 0.9);
assert!(matches!(result, Err(ProtectionError::NoRelaysConfigured)));
}
}