1use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use tracing::info;
18
19use crate::network::{
20 BusType, LccHvdcControlMode, Network, NodeBreakerTopology, SwitchType, VscHvdcControlMode,
21};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type")]
36pub enum ContingencyModification {
37 BranchClose {
41 from_bus: u32,
42 to_bus: u32,
43 circuit: String,
44 },
45 BranchTap {
49 from_bus: u32,
50 to_bus: u32,
51 circuit: String,
52 tap: f64,
53 },
54 LoadSet { bus: u32, p_mw: f64, q_mvar: f64 },
59 LoadAdjust {
64 bus: u32,
65 delta_p_mw: f64,
66 delta_q_mvar: f64,
67 },
68 GenOutputSet {
72 bus: u32,
73 machine_id: String,
74 p_mw: f64,
75 },
76 GenLimitSet {
80 bus: u32,
81 machine_id: String,
82 pmax_mw: Option<f64>,
83 pmin_mw: Option<f64>,
84 },
85 ShuntAdjust { bus: u32, delta_b_pu: f64 },
92 BusTypeChange { bus: u32, bus_type: u32 },
96 AreaScheduleSet { area: u32, p_mw: f64 },
100 DcLineBlock { name: String },
107 VscDcLineBlock { name: String },
114 SwitchedShuntRemove { bus: u32 },
125 DcGridConverterTrip { converter_id: String },
133}
134
135#[derive(Debug, Error, Clone, PartialEq, Eq)]
137pub enum ContingencyModificationError {
138 #[error("{operation}: bus {bus} not found in network")]
140 MissingBus { operation: &'static str, bus: u32 },
141 #[error("{operation}: branch {from_bus}-{to_bus} ckt {circuit} not found in network")]
143 MissingBranch {
144 operation: &'static str,
145 from_bus: u32,
146 to_bus: u32,
147 circuit: String,
148 },
149 #[error("{operation}: generator {machine_id} at bus {bus} not found in network")]
151 MissingGenerator {
152 operation: &'static str,
153 bus: u32,
154 machine_id: String,
155 },
156 #[error("{operation}: area {area} not found in network")]
158 MissingAreaSchedule { operation: &'static str, area: u32 },
159 #[error("{operation}: HVDC link `{name}` not found in network")]
161 MissingHvdcLink {
162 operation: &'static str,
163 name: String,
164 },
165 #[error("{operation}: DC-grid converter `{converter_id}` not found")]
167 MissingDcGridConverter {
168 operation: &'static str,
169 converter_id: String,
170 },
171 #[error("{operation}: no switched shunt exists at bus {bus}")]
173 MissingSwitchedShunt { operation: &'static str, bus: u32 },
174 #[error("{operation}: invalid bus type code {bus_type} for bus {bus}")]
176 InvalidBusType {
177 operation: &'static str,
178 bus: u32,
179 bus_type: u32,
180 },
181}
182
183pub fn apply_contingency_modifications(
191 network: &mut Network,
192 modifications: &[ContingencyModification],
193) -> Result<(), ContingencyModificationError> {
194 if modifications.is_empty() {
195 return Ok(());
196 }
197 let bus_map = network.bus_index_map();
198 for modification in modifications {
199 match modification {
200 ContingencyModification::BranchClose {
201 from_bus,
202 to_bus,
203 circuit,
204 } => {
205 let mut matched = false;
206 for br in &mut network.branches {
207 if branch_matches(
208 br.from_bus,
209 br.to_bus,
210 &br.circuit,
211 *from_bus,
212 *to_bus,
213 circuit,
214 ) {
215 br.in_service = true;
216 matched = true;
217 }
218 }
219 if !matched {
220 return Err(ContingencyModificationError::MissingBranch {
221 operation: "BranchClose",
222 from_bus: *from_bus,
223 to_bus: *to_bus,
224 circuit: circuit.clone(),
225 });
226 }
227 }
228 ContingencyModification::BranchTap {
229 from_bus,
230 to_bus,
231 circuit,
232 tap,
233 } => {
234 let mut matched = false;
235 for br in &mut network.branches {
236 if branch_matches(
237 br.from_bus,
238 br.to_bus,
239 &br.circuit,
240 *from_bus,
241 *to_bus,
242 circuit,
243 ) {
244 br.tap = *tap;
245 matched = true;
246 }
247 }
248 if !matched {
249 return Err(ContingencyModificationError::MissingBranch {
250 operation: "BranchTap",
251 from_bus: *from_bus,
252 to_bus: *to_bus,
253 circuit: circuit.clone(),
254 });
255 }
256 }
257 ContingencyModification::LoadSet { bus, p_mw, q_mvar } => {
258 if !bus_map.contains_key(bus) {
259 return Err(ContingencyModificationError::MissingBus {
260 operation: "LoadSet",
261 bus: *bus,
262 });
263 }
264 let loads_at_bus: Vec<usize> = network
268 .loads
269 .iter()
270 .enumerate()
271 .filter(|(_, l)| l.bus == *bus)
272 .map(|(i, _)| i)
273 .collect();
274 if loads_at_bus.is_empty() {
275 network
277 .loads
278 .push(crate::network::Load::new(*bus, *p_mw, *q_mvar));
279 } else {
280 for (rank, &li) in loads_at_bus.iter().enumerate() {
282 if rank == 0 {
283 network.loads[li].active_power_demand_mw = *p_mw;
284 network.loads[li].reactive_power_demand_mvar = *q_mvar;
285 network.loads[li].in_service = true;
286 } else {
287 network.loads[li].active_power_demand_mw = 0.0;
288 network.loads[li].reactive_power_demand_mvar = 0.0;
289 }
290 }
291 }
292 }
293 ContingencyModification::LoadAdjust {
294 bus,
295 delta_p_mw,
296 delta_q_mvar,
297 } => {
298 if !bus_map.contains_key(bus) {
299 return Err(ContingencyModificationError::MissingBus {
300 operation: "LoadAdjust",
301 bus: *bus,
302 });
303 }
304 let loads_at_bus: Vec<usize> = network
306 .loads
307 .iter()
308 .enumerate()
309 .filter(|(_, l)| l.bus == *bus && l.in_service)
310 .map(|(i, _)| i)
311 .collect();
312 if loads_at_bus.is_empty() {
313 network
315 .loads
316 .push(crate::network::Load::new(*bus, *delta_p_mw, *delta_q_mvar));
317 } else if loads_at_bus.len() == 1 {
318 let li = loads_at_bus[0];
320 network.loads[li].active_power_demand_mw += delta_p_mw;
321 network.loads[li].reactive_power_demand_mvar += delta_q_mvar;
322 } else {
323 let total_p: f64 = loads_at_bus
325 .iter()
326 .map(|&i| network.loads[i].active_power_demand_mw.abs())
327 .sum();
328 let total_q: f64 = loads_at_bus
329 .iter()
330 .map(|&i| network.loads[i].reactive_power_demand_mvar.abs())
331 .sum();
332 for &li in &loads_at_bus {
333 let p_frac = if total_p > 1e-12 {
334 network.loads[li].active_power_demand_mw.abs() / total_p
335 } else {
336 1.0 / loads_at_bus.len() as f64
337 };
338 let q_frac = if total_q > 1e-12 {
339 network.loads[li].reactive_power_demand_mvar.abs() / total_q
340 } else {
341 1.0 / loads_at_bus.len() as f64
342 };
343 network.loads[li].active_power_demand_mw += delta_p_mw * p_frac;
344 network.loads[li].reactive_power_demand_mvar += delta_q_mvar * q_frac;
345 }
346 }
347 }
348 ContingencyModification::GenOutputSet {
349 bus,
350 machine_id,
351 p_mw,
352 } => {
353 let mut matched = false;
354 for g in &mut network.generators {
355 if g.bus == *bus
356 && g.machine_id.as_deref().unwrap_or("1") == machine_id.as_str()
357 {
358 g.p = *p_mw;
360 matched = true;
361 }
362 }
363 if !matched {
364 return Err(ContingencyModificationError::MissingGenerator {
365 operation: "GenOutputSet",
366 bus: *bus,
367 machine_id: machine_id.clone(),
368 });
369 }
370 }
371 ContingencyModification::GenLimitSet {
372 bus,
373 machine_id,
374 pmax_mw,
375 pmin_mw,
376 } => {
377 let mut matched = false;
378 for g in &mut network.generators {
379 if g.bus == *bus
380 && g.machine_id.as_deref().unwrap_or("1") == machine_id.as_str()
381 {
382 if let Some(pmax) = pmax_mw {
383 g.pmax = *pmax;
384 }
385 if let Some(pmin) = pmin_mw {
386 g.pmin = *pmin;
387 }
388 matched = true;
389 }
390 }
391 if !matched {
392 return Err(ContingencyModificationError::MissingGenerator {
393 operation: "GenLimitSet",
394 bus: *bus,
395 machine_id: machine_id.clone(),
396 });
397 }
398 }
399 ContingencyModification::ShuntAdjust { bus, delta_b_pu } => {
400 let Some(&idx) = bus_map.get(bus) else {
401 return Err(ContingencyModificationError::MissingBus {
402 operation: "ShuntAdjust",
403 bus: *bus,
404 });
405 };
406 network.buses[idx].shunt_susceptance_mvar += delta_b_pu * network.base_mva;
407 }
408 ContingencyModification::BusTypeChange { bus, bus_type } => {
409 let Some(&idx) = bus_map.get(bus) else {
410 return Err(ContingencyModificationError::MissingBus {
411 operation: "BusTypeChange",
412 bus: *bus,
413 });
414 };
415 let new_type = match bus_type {
416 1 => BusType::PQ,
417 2 => BusType::PV,
418 3 => BusType::Slack,
419 _ => {
420 return Err(ContingencyModificationError::InvalidBusType {
421 operation: "BusTypeChange",
422 bus: *bus,
423 bus_type: *bus_type,
424 });
425 }
426 };
427 network.buses[idx].bus_type = new_type;
428 }
429 ContingencyModification::AreaScheduleSet { area, p_mw } => {
430 let mut matched = false;
431 for ai in &mut network.area_schedules {
432 if ai.number == *area {
433 ai.p_desired_mw = *p_mw;
434 matched = true;
435 }
436 }
437 if !matched {
438 return Err(ContingencyModificationError::MissingAreaSchedule {
439 operation: "AreaScheduleSet",
440 area: *area,
441 });
442 }
443 }
444 ContingencyModification::DcLineBlock { name } => {
445 let mut found = false;
446 for link in &mut network.hvdc.links {
447 if let Some(dc) = link.as_lcc_mut()
448 && dc.name == *name
449 {
450 dc.mode = LccHvdcControlMode::Blocked;
451 found = true;
452 }
453 }
454 if !found {
455 return Err(ContingencyModificationError::MissingHvdcLink {
456 operation: "DcLineBlock",
457 name: name.clone(),
458 });
459 }
460 }
461 ContingencyModification::VscDcLineBlock { name } => {
462 let mut found = false;
463 for link in &mut network.hvdc.links {
464 if let Some(vsc) = link.as_vsc_mut()
465 && vsc.name == *name
466 {
467 vsc.mode = VscHvdcControlMode::Blocked;
468 found = true;
469 }
470 }
471 if !found {
472 return Err(ContingencyModificationError::MissingHvdcLink {
473 operation: "VscDcLineBlock",
474 name: name.clone(),
475 });
476 }
477 }
478 ContingencyModification::SwitchedShuntRemove { bus } => {
479 let Some(&bus_idx) = bus_map.get(bus) else {
481 return Err(ContingencyModificationError::MissingBus {
482 operation: "SwitchedShuntRemove",
483 bus: *bus,
484 });
485 };
486
487 let mut removed = false;
491 for ss in &mut network.controls.switched_shunts {
492 if ss.bus == *bus {
493 ss.n_steps_cap = 0;
494 ss.n_steps_react = 0;
495 ss.n_active_steps = 0;
496 removed = true;
497 info!("SwitchedShuntRemove: zeroed discrete shunt at bus {}", bus);
498 }
499 }
500
501 if !removed {
505 for ss in &mut network.controls.switched_shunts_opf {
506 if ss.bus == *bus {
507 network.buses[bus_idx].shunt_susceptance_mvar -=
508 ss.b_init_pu * network.base_mva;
509 ss.b_min_pu = 0.0;
510 ss.b_max_pu = 0.0;
511 ss.b_init_pu = 0.0;
512 removed = true;
513 break;
514 }
515 }
516 }
517
518 if !removed {
520 return Err(ContingencyModificationError::MissingSwitchedShunt {
521 operation: "SwitchedShuntRemove",
522 bus: *bus,
523 });
524 }
525 }
526 ContingencyModification::DcGridConverterTrip { converter_id } => {
527 let mut found = false;
528 for grid in &mut network.hvdc.dc_grids {
529 for converter in &mut grid.converters {
530 if converter.id() != converter_id {
531 continue;
532 }
533 if let Some(lcc) = converter.as_lcc_mut() {
534 lcc.in_service = false;
535 }
536 if let Some(vsc) = converter.as_vsc_mut() {
537 vsc.status = false;
538 }
539 found = true;
540 }
541 }
542 if !found {
543 return Err(ContingencyModificationError::MissingDcGridConverter {
544 operation: "DcGridConverterTrip",
545 converter_id: converter_id.clone(),
546 });
547 }
548 }
549 }
550 }
551
552 Ok(())
553}
554
555fn branch_matches(
556 br_from: u32,
557 br_to: u32,
558 br_circuit: &str,
559 query_from: u32,
560 query_to: u32,
561 query_circuit: &str,
562) -> bool {
563 br_circuit == query_circuit
564 && ((br_from == query_from && br_to == query_to)
565 || (br_from == query_to && br_to == query_from))
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
573pub enum TplCategory {
574 #[default]
576 Unclassified,
577 P1SingleElement,
579 P2SingleWithRAS,
581 P3GeneratorTrip,
583 P4StuckBreaker,
585 P5DelayedClearing,
587 P6SameTower,
589 P6CommonCorridor,
591 P6ParallelCircuits,
593 P7CommonMode,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct Contingency {
600 pub id: String,
602 pub label: String,
604 pub branch_indices: Vec<usize>,
606 pub generator_indices: Vec<usize>,
608 #[serde(default)]
614 pub hvdc_converter_indices: Vec<usize>,
615 #[serde(default)]
620 pub hvdc_cable_indices: Vec<usize>,
621 #[serde(default, skip_serializing_if = "Vec::is_empty")]
628 pub switch_ids: Vec<String>,
629 #[serde(default)]
634 pub tpl_category: TplCategory,
635 #[serde(default, skip_serializing_if = "Vec::is_empty")]
641 pub modifications: Vec<ContingencyModification>,
642}
643
644impl Default for Contingency {
645 fn default() -> Self {
646 Self {
647 id: String::new(),
648 label: String::new(),
649 branch_indices: vec![],
650 generator_indices: vec![],
651 hvdc_converter_indices: vec![],
652 hvdc_cable_indices: vec![],
653 switch_ids: vec![],
654 tpl_category: TplCategory::Unclassified,
655 modifications: vec![],
656 }
657 }
658}
659
660pub fn generate_n1_branch_contingencies(network: &Network) -> Vec<Contingency> {
662 let contingencies: Vec<Contingency> = network
663 .branches
664 .iter()
665 .enumerate()
666 .filter(|(_, br)| br.in_service)
667 .map(|(i, br)| Contingency {
668 id: format!("branch_{i}"),
669 label: format!("Line {}->{}(ckt {})", br.from_bus, br.to_bus, br.circuit),
670 branch_indices: vec![i],
671 tpl_category: TplCategory::P1SingleElement,
672 ..Default::default()
673 })
674 .collect();
675 info!(
676 buses = network.n_buses(),
677 branches = network.n_branches(),
678 contingencies = contingencies.len(),
679 "generated N-1 branch contingencies"
680 );
681 contingencies
682}
683
684pub fn generate_breaker_contingencies(model: &NodeBreakerTopology) -> Vec<Contingency> {
690 let contingencies: Vec<Contingency> = model
691 .switches
692 .iter()
693 .filter(|sw| sw.switch_type == SwitchType::Breaker && !sw.open)
694 .map(|sw| Contingency {
695 id: format!("breaker_{}", sw.id),
696 label: format!("Trip breaker {}", sw.name),
697 switch_ids: vec![sw.id.clone()],
698 ..Default::default()
699 })
700 .collect();
701 info!(
702 breakers = contingencies.len(),
703 "generated breaker contingencies"
704 );
705 contingencies
706}
707
708pub fn generate_p4_stuck_breaker_contingencies(network: &Network) -> Vec<Contingency> {
727 let mut bus_to_branches: HashMap<u32, Vec<usize>> = HashMap::new();
729 for (i, br) in network.branches.iter().enumerate() {
730 if !br.in_service {
731 continue;
732 }
733 bus_to_branches.entry(br.from_bus).or_default().push(i);
734 bus_to_branches.entry(br.to_bus).or_default().push(i);
735 }
736
737 let mut bus_to_gens: HashMap<u32, Vec<usize>> = HashMap::new();
739 for (i, g) in network.generators.iter().enumerate() {
740 if g.in_service {
741 bus_to_gens.entry(g.bus).or_default().push(i);
742 }
743 }
744
745 let mut seen: std::collections::HashSet<(Vec<usize>, Vec<usize>)> =
747 std::collections::HashSet::new();
748 let mut contingencies = Vec::new();
749
750 for (i, br) in network.branches.iter().enumerate() {
751 if !br.in_service {
752 continue;
753 }
754
755 for &bus in &[br.from_bus, br.to_bus] {
757 let mut branch_indices: Vec<usize> =
759 bus_to_branches.get(&bus).cloned().unwrap_or_default();
760
761 if !branch_indices.contains(&i) {
763 branch_indices.push(i);
764 }
765 branch_indices.sort_unstable();
766 branch_indices.dedup();
767
768 let mut gen_indices: Vec<usize> = bus_to_gens.get(&bus).cloned().unwrap_or_default();
770 gen_indices.sort_unstable();
771
772 let sig = (branch_indices.clone(), gen_indices.clone());
774 if !seen.insert(sig) {
775 continue;
776 }
777
778 let n_elements = branch_indices.len() + gen_indices.len();
779 let branch_labels: Vec<String> = branch_indices
780 .iter()
781 .map(|&idx| {
782 let b = &network.branches[idx];
783 format!("{}->{}({})", b.from_bus, b.to_bus, b.circuit)
784 })
785 .collect();
786
787 contingencies.push(Contingency {
788 id: format!("p4_br{i}_bus{bus}"),
789 label: format!(
790 "P4 stuck breaker bus {bus}: {n_elements} elements [{}]",
791 branch_labels.join(", ")
792 ),
793 branch_indices,
794 generator_indices: gen_indices,
795 tpl_category: TplCategory::P4StuckBreaker,
796 ..Default::default()
797 });
798 }
799 }
800
801 info!(
802 contingencies = contingencies.len(),
803 branches = network.n_branches(),
804 "generated P4 stuck-breaker contingencies"
805 );
806 contingencies
807}
808
809pub fn generate_p5_contingencies(network: &Network) -> Vec<Contingency> {
821 let mut contingencies = Vec::new();
822
823 for (i, br) in network.branches.iter().enumerate() {
824 if !br.in_service {
825 continue;
826 }
827 contingencies.push(Contingency {
828 id: format!("p5_branch_{i}"),
829 label: format!(
830 "P5 delayed clearing: {}→{}({})",
831 br.from_bus, br.to_bus, br.circuit
832 ),
833 branch_indices: vec![i],
834 tpl_category: TplCategory::P5DelayedClearing,
835 ..Default::default()
836 });
837 }
838
839 info!(
840 contingencies = contingencies.len(),
841 branches = network.n_branches(),
842 "generated P5 delayed-clearing contingencies"
843 );
844 contingencies
845}
846
847pub fn generate_p6_parallel_contingencies(network: &Network) -> Vec<Contingency> {
857 let mut groups: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
859 for (i, br) in network.branches.iter().enumerate() {
860 if !br.in_service {
861 continue;
862 }
863 let key = if br.from_bus <= br.to_bus {
864 (br.from_bus, br.to_bus)
865 } else {
866 (br.to_bus, br.from_bus)
867 };
868 groups.entry(key).or_default().push(i);
869 }
870
871 let mut contingencies = Vec::new();
872 for ((bus_lo, bus_hi), indices) in &groups {
873 if indices.len() < 2 {
874 continue;
875 }
876 for (ia, &a) in indices.iter().enumerate() {
878 for &b in &indices[ia + 1..] {
879 let br_a = &network.branches[a];
880 let br_b = &network.branches[b];
881 contingencies.push(Contingency {
882 id: format!("p6c_br{a}_{b}"),
883 label: format!(
884 "P6c parallel {bus_lo}->{bus_hi}: ckt {} + ckt {}",
885 br_a.circuit, br_b.circuit
886 ),
887 branch_indices: vec![a, b],
888 tpl_category: TplCategory::P6ParallelCircuits,
889 ..Default::default()
890 });
891 }
892 }
893 }
894
895 info!(
896 contingencies = contingencies.len(),
897 "generated P6c parallel-circuit contingencies"
898 );
899 contingencies
900}
901
902pub fn generate_p6_user_pairs(
911 network: &Network,
912 pairs: &[(usize, usize)],
913 category: TplCategory,
914) -> Vec<Contingency> {
915 let cat_label = match category {
916 TplCategory::P6SameTower => "P6a tower",
917 TplCategory::P6CommonCorridor => "P6b corridor",
918 _ => "P6 user",
919 };
920 let n_br = network.branches.len();
921
922 let contingencies: Vec<Contingency> = pairs
923 .iter()
924 .filter(|&&(a, b)| {
925 a < n_br
926 && b < n_br
927 && a != b
928 && network.branches[a].in_service
929 && network.branches[b].in_service
930 })
931 .map(|&(a, b)| {
932 let br_a = &network.branches[a];
933 let br_b = &network.branches[b];
934 Contingency {
935 id: format!("p6_{a}_{b}"),
936 label: format!(
937 "{cat_label}: {}->{}({}) + {}->{}({})",
938 br_a.from_bus,
939 br_a.to_bus,
940 br_a.circuit,
941 br_b.from_bus,
942 br_b.to_bus,
943 br_b.circuit,
944 ),
945 branch_indices: vec![a, b],
946 tpl_category: category,
947 ..Default::default()
948 }
949 })
950 .collect();
951
952 info!(
953 contingencies = contingencies.len(),
954 pairs_supplied = pairs.len(),
955 category = cat_label,
956 "generated P6 user-specified contingencies"
957 );
958 contingencies
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use crate::network::{
965 Branch, Bus, BusType, DcBus, DcConverter, DcConverterStation, Generator, HvdcLink,
966 LccConverterTerminal, LccHvdcLink, SwitchDevice, SwitchedShunt, VscConverterTerminal,
967 VscHvdcLink,
968 };
969
970 #[test]
971 fn test_contingency_hvdc_fields() {
972 let ctg = Contingency {
973 id: "hvdc_conv_0".into(),
974 label: "Trip HVDC converter 0".into(),
975 hvdc_converter_indices: vec![0],
976 ..Default::default()
977 };
978 assert_eq!(ctg.hvdc_converter_indices, vec![0]);
979 assert!(ctg.hvdc_cable_indices.is_empty());
980
981 let json = serde_json::to_string(&ctg).unwrap();
982 let deser: Contingency = serde_json::from_str(&json).unwrap();
983 assert_eq!(deser.hvdc_converter_indices, vec![0]);
984 assert!(deser.hvdc_cable_indices.is_empty());
985
986 let json_legacy =
988 r#"{"id":"br_0","label":"x","branch_indices":[0],"generator_indices":[]}"#;
989 let deser_legacy: Contingency = serde_json::from_str(json_legacy).unwrap();
990 assert!(deser_legacy.hvdc_converter_indices.is_empty());
991 assert!(deser_legacy.hvdc_cable_indices.is_empty());
992 assert_eq!(deser_legacy.tpl_category, TplCategory::Unclassified);
993 }
994
995 #[test]
996 fn test_contingency_hvdc_cable() {
997 let ctg = Contingency {
998 id: "hvdc_cable_2".into(),
999 label: "Trip HVDC cable 2".into(),
1000 hvdc_cable_indices: vec![2],
1001 ..Default::default()
1002 };
1003 assert_eq!(ctg.hvdc_cable_indices, vec![2]);
1004 assert!(ctg.hvdc_converter_indices.is_empty());
1005 }
1006
1007 #[test]
1008 fn test_breaker_contingency_generation() {
1009 let model = NodeBreakerTopology::new(
1010 Vec::new(),
1011 Vec::new(),
1012 Vec::new(),
1013 Vec::new(),
1014 Vec::new(),
1015 vec![
1016 SwitchDevice {
1017 id: "BRK_1".into(),
1018 name: "Breaker 1".into(),
1019 switch_type: SwitchType::Breaker,
1020 cn1_id: "CN_A".into(),
1021 cn2_id: "CN_B".into(),
1022 open: false,
1023 normal_open: false,
1024 retained: false,
1025 rated_current: None,
1026 },
1027 SwitchDevice {
1028 id: "BRK_2".into(),
1029 name: "Breaker 2".into(),
1030 switch_type: SwitchType::Breaker,
1031 cn1_id: "CN_C".into(),
1032 cn2_id: "CN_D".into(),
1033 open: true,
1034 normal_open: true,
1035 retained: false,
1036 rated_current: None,
1037 },
1038 SwitchDevice {
1039 id: "DIS_1".into(),
1040 name: "Disconnector 1".into(),
1041 switch_type: SwitchType::Disconnector,
1042 cn1_id: "CN_A".into(),
1043 cn2_id: "CN_C".into(),
1044 open: false,
1045 normal_open: false,
1046 retained: false,
1047 rated_current: None,
1048 },
1049 ],
1050 Vec::new(),
1051 );
1052
1053 let ctgs = generate_breaker_contingencies(&model);
1054 assert_eq!(ctgs.len(), 1, "only 1 closed breaker");
1055 assert_eq!(ctgs[0].switch_ids, vec!["BRK_1"]);
1056 assert!(ctgs[0].branch_indices.is_empty());
1057 }
1058
1059 #[test]
1060 fn test_switch_ids_serde_backward_compat() {
1061 let json = r#"{"id":"br_0","label":"x","branch_indices":[0],"generator_indices":[]}"#;
1062 let ctg: Contingency = serde_json::from_str(json).unwrap();
1063 assert!(ctg.switch_ids.is_empty());
1064 assert_eq!(ctg.tpl_category, TplCategory::Unclassified);
1065
1066 let ctg2 = Contingency {
1067 id: "brk_1".into(),
1068 label: "Trip breaker 1".into(),
1069 switch_ids: vec!["BRK_1".into()],
1070 ..Default::default()
1071 };
1072 let json2 = serde_json::to_string(&ctg2).unwrap();
1073 let deser: Contingency = serde_json::from_str(&json2).unwrap();
1074 assert_eq!(deser.switch_ids, vec!["BRK_1"]);
1075 }
1076
1077 #[test]
1078 fn test_tpl_category_serde_backward_compat() {
1079 let json = r#"{"id":"br_0","label":"x","branch_indices":[0],"generator_indices":[]}"#;
1081 let ctg: Contingency = serde_json::from_str(json).unwrap();
1082 assert_eq!(ctg.tpl_category, TplCategory::Unclassified);
1083
1084 let ctg = Contingency {
1086 id: "p4_br0_bus1".into(),
1087 label: "P4 stuck breaker".into(),
1088 branch_indices: vec![0, 1, 2],
1089 generator_indices: vec![0],
1090 tpl_category: TplCategory::P4StuckBreaker,
1091 ..Default::default()
1092 };
1093 let json = serde_json::to_string(&ctg).unwrap();
1094 let deser: Contingency = serde_json::from_str(&json).unwrap();
1095 assert_eq!(deser.tpl_category, TplCategory::P4StuckBreaker);
1096 assert_eq!(deser.branch_indices, vec![0, 1, 2]);
1097 assert_eq!(deser.generator_indices, vec![0]);
1098 }
1099
1100 #[test]
1101 fn test_p4_stuck_breaker_generation() {
1102 let mut net = Network::new("p4_test");
1108 net.base_mva = 100.0;
1109 net.buses = vec![
1110 Bus::new(1, BusType::Slack, 345.0),
1111 Bus::new(2, BusType::PQ, 345.0),
1112 Bus::new(3, BusType::PQ, 345.0),
1113 ];
1114 net.branches = vec![
1115 Branch::new_line(1, 2, 0.01, 0.1, 0.0),
1116 Branch::new_line(2, 3, 0.01, 0.1, 0.0),
1117 Branch::new_line(1, 3, 0.01, 0.1, 0.0),
1118 ];
1119 let mut g = Generator::new(1, 100.0, 0.0);
1120 g.in_service = true;
1121 net.generators = vec![g];
1122
1123 let ctgs = generate_p4_stuck_breaker_contingencies(&net);
1124
1125 assert_eq!(
1134 ctgs.len(),
1135 3,
1136 "3-bus triangle should give 3 unique P4 contingencies"
1137 );
1138
1139 for ctg in &ctgs {
1141 assert_eq!(ctg.tpl_category, TplCategory::P4StuckBreaker);
1142 assert!(ctg.branch_indices.len() >= 2, "P4 trips 2+ elements");
1143 }
1144
1145 let bus1_ctg = ctgs.iter().find(|c| c.id.contains("bus1")).unwrap();
1147 assert_eq!(bus1_ctg.generator_indices, vec![0]);
1148 }
1149
1150 #[test]
1151 fn test_p6_parallel_detection() {
1152 let mut net = Network::new("p6_test");
1154 net.base_mva = 100.0;
1155 net.buses = vec![
1156 Bus::new(1, BusType::Slack, 345.0),
1157 Bus::new(2, BusType::PQ, 345.0),
1158 Bus::new(3, BusType::PQ, 345.0),
1159 ];
1160
1161 let mut br0 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
1162 br0.circuit = "1".to_string();
1163 let mut br1 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
1164 br1.circuit = "2".to_string();
1165 let mut br2 = Branch::new_line(2, 3, 0.01, 0.1, 0.0);
1166 br2.circuit = "1".to_string();
1167 net.branches = vec![br0, br1, br2];
1168
1169 let ctgs = generate_p6_parallel_contingencies(&net);
1170
1171 assert_eq!(ctgs.len(), 1, "one parallel pair between bus 1 and 2");
1173 assert_eq!(ctgs[0].branch_indices, vec![0, 1]);
1174 assert_eq!(ctgs[0].tpl_category, TplCategory::P6ParallelCircuits);
1175 }
1176
1177 #[test]
1178 fn test_p6_user_pairs() {
1179 let mut net = Network::new("p6_user_test");
1180 net.base_mva = 100.0;
1181 net.buses = vec![
1182 Bus::new(1, BusType::Slack, 345.0),
1183 Bus::new(2, BusType::PQ, 345.0),
1184 Bus::new(3, BusType::PQ, 345.0),
1185 ];
1186 net.branches = vec![
1187 Branch::new_line(1, 2, 0.01, 0.1, 0.0),
1188 Branch::new_line(2, 3, 0.01, 0.1, 0.0),
1189 Branch::new_line(1, 3, 0.01, 0.1, 0.0),
1190 ];
1191
1192 let pairs = vec![(0, 2), (1, 2)];
1193 let ctgs = generate_p6_user_pairs(&net, &pairs, TplCategory::P6SameTower);
1194 assert_eq!(ctgs.len(), 2);
1195 for ctg in &ctgs {
1196 assert_eq!(ctg.tpl_category, TplCategory::P6SameTower);
1197 assert_eq!(ctg.branch_indices.len(), 2);
1198 }
1199
1200 let bad_pairs = vec![(0, 99), (0, 0)]; let ctgs = generate_p6_user_pairs(&net, &bad_pairs, TplCategory::P6CommonCorridor);
1203 assert_eq!(ctgs.len(), 0);
1204 }
1205
1206 fn build_dc_network() -> Network {
1211 let mut net = Network::new("dc_test");
1212 net.base_mva = 100.0;
1213 net.buses = vec![
1214 Bus::new(1, BusType::Slack, 100.0),
1215 Bus::new(2, BusType::PQ, 100.0),
1216 ];
1217 net.hvdc.links = vec![HvdcLink::Lcc(LccHvdcLink {
1218 name: "HVDC-1".to_string(),
1219 mode: LccHvdcControlMode::PowerControl,
1220 rectifier: LccConverterTerminal {
1221 bus: 1,
1222 ..LccConverterTerminal::default()
1223 },
1224 inverter: LccConverterTerminal {
1225 bus: 2,
1226 ..LccConverterTerminal::default()
1227 },
1228 ..LccHvdcLink::default()
1229 })];
1230 net.hvdc.links.push(HvdcLink::Vsc(VscHvdcLink {
1231 name: "VSC-1".to_string(),
1232 mode: VscHvdcControlMode::PowerControl,
1233 converter1: VscConverterTerminal {
1234 bus: 1,
1235 ..VscConverterTerminal::default()
1236 },
1237 converter2: VscConverterTerminal {
1238 bus: 2,
1239 ..VscConverterTerminal::default()
1240 },
1241 ..VscHvdcLink::default()
1242 }));
1243 net
1244 }
1245
1246 fn build_explicit_dc_grid_network() -> Network {
1247 let mut net = Network::new("explicit_dc_grid");
1248 net.base_mva = 100.0;
1249 net.buses = vec![
1250 Bus::new(1, BusType::Slack, 230.0),
1251 Bus::new(2, BusType::PQ, 230.0),
1252 ];
1253
1254 let grid = net.hvdc.ensure_dc_grid(1, Some("grid".into()));
1255 grid.buses.push(DcBus {
1256 bus_id: 101,
1257 p_dc_mw: 0.0,
1258 v_dc_pu: 1.0,
1259 base_kv_dc: 320.0,
1260 v_dc_max: 1.1,
1261 v_dc_min: 0.9,
1262 cost: 0.0,
1263 g_shunt_siemens: 0.0,
1264 r_ground_ohm: 0.0,
1265 });
1266 grid.converters.push(DcConverter::Vsc(DcConverterStation {
1267 id: "conv_a".into(),
1268 dc_bus: 101,
1269 ac_bus: 1,
1270 control_type_dc: 2,
1271 control_type_ac: 1,
1272 active_power_mw: 0.0,
1273 reactive_power_mvar: 0.0,
1274 is_lcc: false,
1275 voltage_setpoint_pu: 1.0,
1276 transformer_r_pu: 0.0,
1277 transformer_x_pu: 0.0,
1278 transformer: false,
1279 tap_ratio: 1.0,
1280 filter_susceptance_pu: 0.0,
1281 filter: false,
1282 reactor_r_pu: 0.0,
1283 reactor_x_pu: 0.0,
1284 reactor: false,
1285 base_kv_ac: 230.0,
1286 voltage_max_pu: 1.1,
1287 voltage_min_pu: 0.9,
1288 current_max_pu: 2.0,
1289 status: true,
1290 loss_constant_mw: 0.0,
1291 loss_linear: 0.0,
1292 loss_quadratic_rectifier: 0.0,
1293 loss_quadratic_inverter: 0.0,
1294 droop: 0.0,
1295 power_dc_setpoint_mw: 0.0,
1296 voltage_dc_setpoint_pu: 1.0,
1297 active_power_ac_max_mw: 100.0,
1298 active_power_ac_min_mw: -100.0,
1299 reactive_power_ac_max_mvar: 100.0,
1300 reactive_power_ac_min_mvar: -100.0,
1301 }));
1302
1303 net
1304 }
1305
1306 #[test]
1307 fn dc_line_block_sets_mode_blocked() {
1308 let mut net = build_dc_network();
1309 assert_eq!(
1310 net.hvdc.links[0].as_lcc().unwrap().mode,
1311 LccHvdcControlMode::PowerControl
1312 );
1313
1314 apply_contingency_modifications(
1315 &mut net,
1316 &[ContingencyModification::DcLineBlock {
1317 name: "HVDC-1".into(),
1318 }],
1319 )
1320 .expect("dc line block should succeed");
1321
1322 assert_eq!(
1323 net.hvdc.links[0].as_lcc().unwrap().mode,
1324 LccHvdcControlMode::Blocked,
1325 "DcLineBlock must set mode to Blocked"
1326 );
1327 }
1328
1329 #[test]
1330 fn dc_line_block_unknown_name_errors() {
1331 let mut net = build_dc_network();
1332 let err = apply_contingency_modifications(
1333 &mut net,
1334 &[ContingencyModification::DcLineBlock {
1335 name: "NONEXISTENT".into(),
1336 }],
1337 )
1338 .unwrap_err();
1339 assert!(matches!(
1340 err,
1341 ContingencyModificationError::MissingHvdcLink { .. }
1342 ));
1343 }
1344
1345 #[test]
1346 fn branch_close_rejects_missing_branch() {
1347 let mut net = build_dc_network();
1348 let err = apply_contingency_modifications(
1349 &mut net,
1350 &[ContingencyModification::BranchClose {
1351 from_bus: 9,
1352 to_bus: 10,
1353 circuit: "1".into(),
1354 }],
1355 )
1356 .unwrap_err();
1357 assert!(matches!(
1358 err,
1359 ContingencyModificationError::MissingBranch {
1360 operation: "BranchClose",
1361 from_bus: 9,
1362 to_bus: 10,
1363 circuit,
1364 } if circuit == "1"
1365 ));
1366 }
1367
1368 #[test]
1369 fn vsc_dc_line_block_sets_mode_blocked() {
1370 let mut net = build_dc_network();
1371 assert_eq!(
1372 net.hvdc.links[1].as_vsc().unwrap().mode,
1373 VscHvdcControlMode::PowerControl
1374 );
1375
1376 apply_contingency_modifications(
1377 &mut net,
1378 &[ContingencyModification::VscDcLineBlock {
1379 name: "VSC-1".into(),
1380 }],
1381 )
1382 .expect("vsc dc line block should succeed");
1383
1384 assert_eq!(
1385 net.hvdc.links[1].as_vsc().unwrap().mode,
1386 VscHvdcControlMode::Blocked,
1387 "VscDcLineBlock must set mode to Blocked"
1388 );
1389 }
1390
1391 #[test]
1392 fn dc_grid_converter_trip_sets_converter_out_of_service() {
1393 let mut net = build_explicit_dc_grid_network();
1394 assert!(net.hvdc.dc_grids[0].converters[0].is_in_service());
1395
1396 apply_contingency_modifications(
1397 &mut net,
1398 &[ContingencyModification::DcGridConverterTrip {
1399 converter_id: "conv_a".into(),
1400 }],
1401 )
1402 .expect("dc-grid converter trip should succeed");
1403
1404 assert!(
1405 !net.hvdc.dc_grids[0].converters[0].is_in_service(),
1406 "DcGridConverterTrip must disable the canonical converter"
1407 );
1408 }
1409
1410 #[test]
1411 fn dc_line_block_serde_roundtrip() {
1412 let m = ContingencyModification::DcLineBlock {
1413 name: "HVDC-TEST".into(),
1414 };
1415 let json = serde_json::to_string(&m).unwrap();
1416 assert!(
1417 json.contains(r#""type":"DcLineBlock""#),
1418 "serde must produce tagged JSON"
1419 );
1420 let back: ContingencyModification = serde_json::from_str(&json).unwrap();
1421 assert!(
1422 matches!(back, ContingencyModification::DcLineBlock { name } if name == "HVDC-TEST")
1423 );
1424 }
1425
1426 #[test]
1427 fn vsc_dc_line_block_serde_roundtrip() {
1428 let m = ContingencyModification::VscDcLineBlock {
1429 name: "VSC-TEST".into(),
1430 };
1431 let json = serde_json::to_string(&m).unwrap();
1432 assert!(json.contains(r#""type":"VscDcLineBlock""#));
1433 let back: ContingencyModification = serde_json::from_str(&json).unwrap();
1434 assert!(
1435 matches!(back, ContingencyModification::VscDcLineBlock { name } if name == "VSC-TEST")
1436 );
1437 }
1438
1439 #[test]
1440 fn switched_shunt_remove_serde_roundtrip() {
1441 let m = ContingencyModification::SwitchedShuntRemove { bus: 42 };
1442 let json = serde_json::to_string(&m).unwrap();
1443 assert!(json.contains(r#""type":"SwitchedShuntRemove""#));
1444 let back: ContingencyModification = serde_json::from_str(&json).unwrap();
1445 assert!(matches!(
1446 back,
1447 ContingencyModification::SwitchedShuntRemove { bus: 42 }
1448 ));
1449 }
1450
1451 #[test]
1454 fn switched_shunt_remove_uses_switched_shunts_field() {
1455 let mut net = Network::new("test");
1456 net.base_mva = 100.0;
1457 net.buses = vec![Bus::new(5, BusType::Slack, 100.0)];
1458 net.buses[0].shunt_susceptance_mvar = 0.0; net.controls.switched_shunts = vec![SwitchedShunt {
1461 id: "ssh_5".into(),
1462 bus: 5,
1463 bus_regulated: 5,
1464 b_step: 0.5, n_steps_cap: 4,
1466 n_steps_react: 0,
1467 v_target: 1.0,
1468 v_band: 0.1,
1469 n_active_steps: 3,
1470 }];
1471
1472 let mods = vec![ContingencyModification::SwitchedShuntRemove { bus: 5 }];
1473 apply_contingency_modifications(&mut net, &mods)
1474 .expect("switched shunt removal should succeed");
1475
1476 assert_eq!(net.controls.switched_shunts[0].n_steps_cap, 0);
1478 assert_eq!(net.controls.switched_shunts[0].n_steps_react, 0);
1479 assert_eq!(net.controls.switched_shunts[0].n_active_steps, 0);
1480
1481 assert!(net.buses[0].shunt_susceptance_mvar.abs() < 1e-9);
1483 }
1484
1485 #[test]
1486 fn shunt_adjust_scales_by_base_mva() {
1487 let mut net = Network::new("test");
1488 net.base_mva = 50.0;
1489 net.buses = vec![Bus::new(5, BusType::Slack, 100.0)];
1490 net.buses[0].shunt_susceptance_mvar = 12.0;
1491
1492 apply_contingency_modifications(
1493 &mut net,
1494 &[ContingencyModification::ShuntAdjust {
1495 bus: 5,
1496 delta_b_pu: 0.5,
1497 }],
1498 )
1499 .expect("shunt adjust should succeed");
1500
1501 assert!((net.buses[0].shunt_susceptance_mvar - 37.0).abs() < 1e-9);
1502 }
1503
1504 #[test]
1505 fn branch_modifications_are_direction_insensitive() {
1506 let mut net = Network::new("test");
1507 net.buses = vec![
1508 Bus::new(1, BusType::Slack, 100.0),
1509 Bus::new(2, BusType::PQ, 100.0),
1510 ];
1511 net.branches = vec![crate::network::Branch::new_line(1, 2, 0.01, 0.1, 0.0)];
1512 net.branches[0].in_service = false;
1513 net.branches[0].tap = 1.02;
1514
1515 apply_contingency_modifications(
1516 &mut net,
1517 &[
1518 ContingencyModification::BranchClose {
1519 from_bus: 2,
1520 to_bus: 1,
1521 circuit: "1".to_string(),
1522 },
1523 ContingencyModification::BranchTap {
1524 from_bus: 2,
1525 to_bus: 1,
1526 circuit: "1".to_string(),
1527 tap: 1.08,
1528 },
1529 ],
1530 )
1531 .expect("branch modifications should succeed");
1532
1533 assert!(net.branches[0].in_service);
1534 assert!((net.branches[0].tap - 1.08).abs() < 1e-12);
1535 }
1536
1537 #[test]
1538 fn load_set_rejects_missing_bus() {
1539 let mut net = build_dc_network();
1540 let err = apply_contingency_modifications(
1541 &mut net,
1542 &[ContingencyModification::LoadSet {
1543 bus: 99,
1544 p_mw: 10.0,
1545 q_mvar: 2.0,
1546 }],
1547 )
1548 .unwrap_err();
1549 assert!(matches!(
1550 err,
1551 ContingencyModificationError::MissingBus {
1552 operation: "LoadSet",
1553 bus: 99
1554 }
1555 ));
1556 }
1557
1558 #[test]
1559 fn load_adjust_rejects_missing_bus() {
1560 let mut net = build_dc_network();
1561 let err = apply_contingency_modifications(
1562 &mut net,
1563 &[ContingencyModification::LoadAdjust {
1564 bus: 99,
1565 delta_p_mw: 1.0,
1566 delta_q_mvar: 0.5,
1567 }],
1568 )
1569 .unwrap_err();
1570 assert!(matches!(
1571 err,
1572 ContingencyModificationError::MissingBus {
1573 operation: "LoadAdjust",
1574 bus: 99
1575 }
1576 ));
1577 }
1578
1579 #[test]
1580 fn gen_output_set_rejects_missing_generator() {
1581 let mut net = build_dc_network();
1582 let err = apply_contingency_modifications(
1583 &mut net,
1584 &[ContingencyModification::GenOutputSet {
1585 bus: 1,
1586 machine_id: "9".into(),
1587 p_mw: 42.0,
1588 }],
1589 )
1590 .unwrap_err();
1591 assert!(matches!(
1592 err,
1593 ContingencyModificationError::MissingGenerator {
1594 operation: "GenOutputSet",
1595 bus: 1,
1596 machine_id,
1597 } if machine_id == "9"
1598 ));
1599 }
1600}