Skip to main content

surge_network/network/
contingency.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Contingency definitions for N-1/N-2/extreme-event security analysis.
3//!
4//! A contingency is an outage of one or more network elements (branches,
5//! generators) used in security-constrained power flow and OPF studies.
6//! This module lives in surge-network so that both surge-opf (which needs
7//! `Contingency` as a constraint input) and surge-contingency (which runs
8//! the parallel AC solve) can share the type without a circular dependency.
9//!
10//! NERC TPL-001-5.1 extreme event categories (P4/P5/P6) are supported via
11//! [`TplCategory`] classification and dedicated contingency generators.
12
13use 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/// A network modification applied simultaneously with this contingency.
24///
25/// Represents PSS/E .con `SET`/`CHANGE`/`ALTER`/`MODIFY`/`INCREASE`/`DECREASE`
26/// commands within a `CONTINGENCY` block — network state changes that occur
27/// at the same instant as the element outages.
28///
29/// Applied to the per-contingency network clone before the post-contingency
30/// power flow is solved. The base-case network is never mutated.
31///
32/// **JSON/Python representation**: internally tagged with `"type"` field, e.g.
33/// `{"type": "BranchTap", "from_bus": 1, "to_bus": 2, "circuit": 1, "tap": 1.05}`
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type")]
36pub enum ContingencyModification {
37    /// Bring a branch back in-service (close a previously open branch).
38    ///
39    /// PSS/E .con: `SET STATUS CLOSE BRANCH FROM BUS <i> TO BUS <j> [CKT <c>]`
40    BranchClose {
41        from_bus: u32,
42        to_bus: u32,
43        circuit: String,
44    },
45    /// Set transformer off-nominal tap ratio.
46    ///
47    /// PSS/E .con: `SET TAP OF BRANCH FROM BUS <i> TO BUS <j> [CKT <c>] TO <val>`
48    BranchTap {
49        from_bus: u32,
50        to_bus: u32,
51        circuit: String,
52        tap: f64,
53    },
54    /// Set bus active and reactive load to absolute values (MW, MVAr).
55    ///
56    /// PSS/E .con: `SET PLOAD AT BUS <n> TO <p>` / `CHANGE LOAD AT BUS <n> TO <p> <q>`
57    /// Fails if the target bus does not exist.
58    LoadSet { bus: u32, p_mw: f64, q_mvar: f64 },
59    /// Adjust bus load by a relative change (delta MW, delta MVAr).
60    ///
61    /// PSS/E .con: `INCREASE PLOAD AT BUS <n> BY <delta>` / `DECREASE QLOAD ...`
62    /// Fails if the target bus does not exist.
63    LoadAdjust {
64        bus: u32,
65        delta_p_mw: f64,
66        delta_q_mvar: f64,
67    },
68    /// Set generator real power output directly.
69    ///
70    /// PSS/E .con: `SET PGEN OF UNIT <id> AT BUS <n> TO <val>`
71    GenOutputSet {
72        bus: u32,
73        machine_id: String,
74        p_mw: f64,
75    },
76    /// Set generator real power limit (pmax and/or pmin).
77    ///
78    /// PSS/E .con: `SET PMAX OF UNIT <id> AT BUS <n> TO <val>` / `SET PMIN ...`
79    GenLimitSet {
80        bus: u32,
81        machine_id: String,
82        pmax_mw: Option<f64>,
83        pmin_mw: Option<f64>,
84    },
85    /// Adjust bus fixed shunt susceptance by a delta (p.u.).
86    ///
87    /// The network stores fixed bus shunt susceptance in MVAr, so this delta
88    /// is converted using `network.base_mva` when applied.
89    ///
90    /// PSS/E .con: `CHANGE SHUNT AT BUS <n> BY <delta>` / `INCREASE SHUNT ...`
91    ShuntAdjust { bus: u32, delta_b_pu: f64 },
92    /// Change bus type (1=PQ, 2=PV, 3=Slack).
93    ///
94    /// PSS/E .con: `CHANGE BUS TYPE BUS <n> TO TYPE <t>`
95    BusTypeChange { bus: u32, bus_type: u32 },
96    /// Set area interchange scheduled export.
97    ///
98    /// PSS/E .con: `CHANGE AREA INTERCHANGE <n> TO <val>`
99    AreaScheduleSet { area: u32, p_mw: f64 },
100    /// Block (trip) a two-terminal LCC-HVDC line by name.
101    ///
102    /// Sets `LccHvdcLink.mode = LccHvdcControlMode::Blocked`, causing zero P/Q injection
103    /// at both rectifier and inverter buses.
104    ///
105    /// PSS/E .con: `BLOCK TWOTERMDC '<name>'`
106    DcLineBlock { name: String },
107    /// Block (trip) a VSC-HVDC link by name.
108    ///
109    /// Sets `VscHvdcLink.mode = VscHvdcControlMode::Blocked`, causing zero P/Q injection
110    /// at both converter buses.
111    ///
112    /// PSS/E .con: `BLOCK VSCDC '<name>'`
113    VscDcLineBlock { name: String },
114    /// Remove a switched shunt device from service at a given bus.
115    ///
116    /// Removes the switched shunt's contribution from the bus fixed shunt
117    /// susceptance (`bus.shunt_susceptance_mvar`).  If `switched_shunts_opf` data is available
118    /// for the bus, the device's current `b_init_pu` is subtracted (in MVAr
119    /// after conversion by `network.base_mva`) and its OPF range is zeroed.
120    /// If no switched shunt device is present at the bus, the modification
121    /// fails so the contingency definition stays honest.
122    ///
123    /// PSS/E .con: `REMOVE SWSHUNT [<id>] FROM BUS <n>`
124    SwitchedShuntRemove { bus: u32 },
125    /// Trip a single converter in an explicit DC grid.
126    ///
127    /// Sets the matching canonical explicit-DC converter out of service.
128    /// Fails if the converter does not exist.
129    ///
130    /// Used to model loss of a DC-grid converter terminal during contingency analysis
131    /// without requiring surge-network to depend on surge-hvdc.
132    DcGridConverterTrip { converter_id: String },
133}
134
135/// Error raised when a contingency modification cannot be applied exactly.
136#[derive(Debug, Error, Clone, PartialEq, Eq)]
137pub enum ContingencyModificationError {
138    /// The requested bus does not exist.
139    #[error("{operation}: bus {bus} not found in network")]
140    MissingBus { operation: &'static str, bus: u32 },
141    /// The requested branch does not exist.
142    #[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    /// The requested generator does not exist.
150    #[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    /// The requested area schedule does not exist.
157    #[error("{operation}: area {area} not found in network")]
158    MissingAreaSchedule { operation: &'static str, area: u32 },
159    /// The requested HVDC link does not exist.
160    #[error("{operation}: HVDC link `{name}` not found in network")]
161    MissingHvdcLink {
162        operation: &'static str,
163        name: String,
164    },
165    /// The requested DC-grid converter does not exist.
166    #[error("{operation}: DC-grid converter `{converter_id}` not found")]
167    MissingDcGridConverter {
168        operation: &'static str,
169        converter_id: String,
170    },
171    /// The requested switched shunt does not exist at the target bus.
172    #[error("{operation}: no switched shunt exists at bus {bus}")]
173    MissingSwitchedShunt { operation: &'static str, bus: u32 },
174    /// The requested bus type code is not recognized.
175    #[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
183/// Apply a list of contingency modifications to a cloned network in-place.
184///
185/// Called after element outages are applied, before the post-contingency power
186/// flow is solved. The base-case network is never mutated; only the clone is changed.
187///
188/// Unknown bus numbers, branch pairs, or generator IDs are rejected with an
189/// explicit error so the contingency cannot silently mutate into a different case.
190pub 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                // Set total load at this bus to the specified values.
265                // First zero out all existing loads at this bus, then set the first one
266                // (or create one if none exists) to the target values.
267                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                    // No load at this bus — create one.
276                    network
277                        .loads
278                        .push(crate::network::Load::new(*bus, *p_mw, *q_mvar));
279                } else {
280                    // Set the first load to the target, zero the rest.
281                    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                // Adjust load at this bus proportionally across all loads.
305                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                    // No load at this bus — create one with the delta as the value.
314                    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                    // Single load — apply delta directly.
319                    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                    // Multiple loads — distribute delta proportionally by MW.
324                    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 is in MW; bus_p_injection_pu() divides by base_mva.
359                        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                // Find bus index.
480                let Some(&bus_idx) = bus_map.get(bus) else {
481                    return Err(ContingencyModificationError::MissingBus {
482                        operation: "SwitchedShuntRemove",
483                        bus: *bus,
484                    });
485                };
486
487                // Primary path: discrete SwitchedShunt model (MODSW != 0 shunts).
488                // The shunt's contribution was NOT baked into bus.shunt_susceptance_mvar during parsing,
489                // so we zero out the device's step range — no bus.shunt_susceptance_mvar adjustment needed.
490                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                // Fallback path: OPF continuous shunt model (switched_shunts_opf).
502                // These shunts' BINIT *was* baked into bus.shunt_susceptance_mvar (pre-new-model files),
503                // so subtract b_init_pu from bus.shunt_susceptance_mvar and zero out the OPF variable.
504                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                // No switched shunt device was found at the bus.
519                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/// NERC TPL-001-5.1 contingency category for compliance reporting.
569///
570/// Used to classify contingencies by their NERC planning category so that
571/// the Python TPL checker can route results to the correct P-category handler.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
573pub enum TplCategory {
574    /// Not classified (standard N-1, N-2, or other).
575    #[default]
576    Unclassified,
577    /// P1: Single element outage (line, transformer, generator, shunt, or HVDC pole).
578    P1SingleElement,
579    /// P2: Single element outage with Remedial Action Scheme (RAS) activation.
580    P2SingleWithRAS,
581    /// P3: Generator trip (loss of the largest single generating unit).
582    P3GeneratorTrip,
583    /// P4: Stuck breaker — fault + breaker failure causes loss of entire bus section.
584    P4StuckBreaker,
585    /// P5: Delayed clearing — extended fault duration due to relay/communication failure.
586    P5DelayedClearing,
587    /// P6a: Two elements on same tower/structure.
588    P6SameTower,
589    /// P6b: Two elements in common corridor / right-of-way.
590    P6CommonCorridor,
591    /// P6c: Two parallel circuits (same from/to bus pair).
592    P6ParallelCircuits,
593    /// P7: Common-mode outage (two or more elements from a single common cause).
594    P7CommonMode,
595}
596
597/// A single contingency definition (one or more element outages).
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct Contingency {
600    /// Unique identifier (e.g. "branch_42").
601    pub id: String,
602    /// Human-readable label (e.g. "Line 123->456 (ckt 1)").
603    pub label: String,
604    /// Branch indices to trip (usually 1 for N-1).
605    pub branch_indices: Vec<usize>,
606    /// Generator indices to trip (empty for branch-only N-1).
607    pub generator_indices: Vec<usize>,
608    /// HVDC converter indices to trip (for HVDC contingencies).
609    ///
610    /// Each index refers to the position in the HVDC link list extracted from
611    /// the network (via `hvdc_links_from_network`). Tripping a converter
612    /// removes its P/Q injection at both the rectifier and inverter buses.
613    #[serde(default)]
614    pub hvdc_converter_indices: Vec<usize>,
615    /// HVDC cable (DC branch) indices to trip (for HVDC contingencies).
616    ///
617    /// Tripping a DC cable removes the P injection at both ends of the
618    /// corresponding HVDC link, equivalent to setting P_dc = 0 for that link.
619    #[serde(default)]
620    pub hvdc_cable_indices: Vec<usize>,
621    /// Switch mRIDs to toggle for breaker contingencies.
622    ///
623    /// When non-empty this is a **breaker contingency** that requires topology
624    /// re-reduction (via `surge_topology::rebuild_topology`) before power flow.
625    /// Each entry is the mRID of a switch to open (for trip contingencies) or
626    /// close (for restoration studies).
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub switch_ids: Vec<String>,
629    /// NERC TPL-001-5.1 category classification.
630    ///
631    /// Set by the P4/P5/P6 contingency generators; defaults to `Unclassified`
632    /// for standard N-1/N-2 contingencies.
633    #[serde(default)]
634    pub tpl_category: TplCategory,
635    /// Simultaneous network modifications (PSS/E .con SET/CHANGE commands).
636    ///
637    /// Applied to the per-contingency network clone immediately after element
638    /// outages, before the post-contingency power flow is solved.
639    /// Empty for standard N-1/N-2 contingencies.
640    #[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
660/// Generate one N-1 contingency per in-service branch.
661pub 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
684/// Generate one breaker contingency per closed breaker in the [`NodeBreakerTopology`].
685///
686/// Each contingency opens a single breaker, which may split a bus and change
687/// the network topology.  These contingencies require topology re-reduction
688/// (via `surge_topology::rebuild_topology`) rather than a simple branch-trip.
689pub 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
708// ---------------------------------------------------------------------------
709// NERC TPL-001-5.1 Extreme Event Contingency Generators
710// ---------------------------------------------------------------------------
711
712/// Generate P4 stuck-breaker contingencies (NERC TPL-001-5.1 Category P4).
713///
714/// For each in-service branch, simulates a stuck breaker at each endpoint bus.
715/// A stuck breaker at bus B means the fault on the initiating element is not
716/// cleared locally, so backup protection trips **all** elements connected to
717/// that bus section:
718/// - All other branches connected to bus B
719/// - All generators at bus B
720///
721/// This produces up to 2 contingencies per branch (one per endpoint).
722/// Contingencies are deduplicated by their sorted element sets.
723///
724/// In a bus-branch model without explicit breaker topology, "bus section"
725/// is equivalent to "all elements at the bus number".
726pub fn generate_p4_stuck_breaker_contingencies(network: &Network) -> Vec<Contingency> {
727    // Build bus → branch adjacency
728    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    // Build bus → generator adjacency
738    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    // Track unique contingency signatures to avoid duplicates
746    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 each endpoint of the faulted branch
756        for &bus in &[br.from_bus, br.to_bus] {
757            // Collect all branches at this bus (including the initiating one)
758            let mut branch_indices: Vec<usize> =
759                bus_to_branches.get(&bus).cloned().unwrap_or_default();
760
761            // Ensure the initiating branch is included
762            if !branch_indices.contains(&i) {
763                branch_indices.push(i);
764            }
765            branch_indices.sort_unstable();
766            branch_indices.dedup();
767
768            // Collect all generators at this bus
769            let mut gen_indices: Vec<usize> = bus_to_gens.get(&bus).cloned().unwrap_or_default();
770            gen_indices.sort_unstable();
771
772            // Dedup: skip if we've seen this exact combination
773            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
809/// Generate P5 delayed-clearing contingencies (NERC TPL-001-5.1 Category P5).
810///
811/// Creates one contingency per in-service branch, tagged as
812/// [`TplCategory::P5DelayedClearing`]. These contingencies represent
813/// single-line faults with protection relay or communication failure,
814/// resulting in delayed clearing times (typically 15–30 cycles vs 5 cycles
815/// for normal clearing).
816///
817/// Use with `surge_dyn::p5::run_p5_from_contingencies()` for filtered
818/// screening that only simulates delayed clearing on branches stable
819/// under normal clearing.
820pub 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
847/// Generate P6c parallel-circuit contingencies (NERC TPL-001-5.1 Category P6).
848///
849/// Auto-detects branches sharing the same `(from_bus, to_bus)` bus pair
850/// (parallel circuits). For each group of 2+ parallel circuits, generates
851/// C(n,2) contingencies tripping each pair simultaneously.
852///
853/// This is the automatic detection path. For same-tower (P6a) or
854/// common-corridor (P6b) pairs that require engineering judgment,
855/// use [`generate_p6_user_pairs`] with explicit pair lists.
856pub fn generate_p6_parallel_contingencies(network: &Network) -> Vec<Contingency> {
857    // Group in-service branches by normalized bus pair
858    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        // Generate all C(n,2) pairs
877        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
902/// Generate P6 contingencies from user-specified branch pairs.
903///
904/// Used for P6a (same tower) and P6b (common corridor) contingencies
905/// that cannot be auto-detected from the network model and require
906/// engineering judgment.
907///
908/// Each entry in `pairs` is `(branch_idx_a, branch_idx_b)`.
909/// Invalid indices (out of range or out of service) are silently skipped.
910pub 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        // Deserialize without HVDC/tpl_category fields (backward compat).
987        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        // Old JSON without tpl_category should deserialize with Unclassified.
1080        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        // Round-trip with P4 category.
1085        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        // Build a 3-bus triangle network:
1103        //   Bus 1 --[br0]--> Bus 2
1104        //   Bus 2 --[br1]--> Bus 3
1105        //   Bus 1 --[br2]--> Bus 3
1106        // Generator at bus 1.
1107        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        // Bus 1 has br0, br2, gen0 → stuck breaker at bus 1 trips {br0,br2} + {gen0}
1126        // Bus 2 has br0, br1 → stuck breaker at bus 2 trips {br0,br1}
1127        // Bus 3 has br1, br2 → stuck breaker at bus 3 trips {br1,br2}
1128        // For br0 (1→2): bus 1 → {br0,br2,gen0}, bus 2 → {br0,br1}
1129        // For br1 (2→3): bus 2 → {br0,br1} (already seen), bus 3 → {br1,br2}
1130        // For br2 (1→3): bus 1 → {br0,br2,gen0} (already seen), bus 3 → {br1,br2} (already seen)
1131        // Unique: {br0,br2,gen0}, {br0,br1}, {br1,br2} = 3 contingencies
1132
1133        assert_eq!(
1134            ctgs.len(),
1135            3,
1136            "3-bus triangle should give 3 unique P4 contingencies"
1137        );
1138
1139        // All should be P4
1140        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        // The bus-1 contingency should include the generator
1146        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        // Build a network with parallel circuits between bus 1 and bus 2.
1153        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        // Only branches 0 and 1 are parallel (1→2, ckt 1 and ckt 2)
1172        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        // Invalid pairs should be silently skipped
1201        let bad_pairs = vec![(0, 99), (0, 0)]; // out of range, self-pair
1202        let ctgs = generate_p6_user_pairs(&net, &bad_pairs, TplCategory::P6CommonCorridor);
1203        assert_eq!(ctgs.len(), 0);
1204    }
1205
1206    // -----------------------------------------------------------------------
1207    // DcLineBlock / VscDcLineBlock / SwitchedShuntRemove
1208    // -----------------------------------------------------------------------
1209
1210    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    /// SwitchedShuntRemove must zero out the discrete SwitchedShunt at the target
1452    /// bus without touching bus.shunt_susceptance_mvar (the shunt's BINIT was never baked in).
1453    #[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        // Controlled shunt at bus 5 — contribution NOT in bus.shunt_susceptance_mvar.
1459        net.buses[0].shunt_susceptance_mvar = 0.0; // explicitly zero
1460        net.controls.switched_shunts = vec![SwitchedShunt {
1461            id: "ssh_5".into(),
1462            bus: 5,
1463            bus_regulated: 5,
1464            b_step: 0.5, // 50 Mvar / 100 MVA
1465            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        // Steps zeroed out — shunt is disabled.
1477        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        // bus.shunt_susceptance_mvar must not have changed (controlled shunt was never baked in).
1482        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}