Skip to main content

oxigrid/network/
dc_switching.rs

1//! HVDC switching transient analysis, converter station dynamics, DC fault
2//! analysis, and breaker modeling for multi-terminal DC grids.
3//!
4//! # Algorithms
5//!
6//! - **DC power flow**: Newton's method on the DC grid conductance matrix.
7//! - **LCC converter**: `V_dc = (3√2/π)·V_ac·cos(α) − (3/π)·X_c·I_dc`
8//! - **VSC converter**: `V_dc = V_ac·m·√(3/2)`, independent P/Q control.
9//! - **DC fault analysis**: RLC circuit (underdamped/overdamped).
10//! - **Breaker clearing**: detection → trip delay → opening → energy absorption.
11//! - **Transient simulation**: RK4 on state-space `[V_dc_1..N, I_cable_1..M]`.
12
13use std::f64::consts::{PI, SQRT_2};
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Types
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Converter topology type.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ConverterTopology {
22    /// Line Commutated Converter (thyristor).
23    Lcc,
24    /// Two-level VSC (IGBT).
25    TwoLevelVsc,
26    /// Modular Multilevel Converter.
27    Mmc {
28        /// Number of sub-modules per arm.
29        n_submodules: usize,
30    },
31    /// Hybrid (LCC + VSC).
32    Hybrid,
33}
34
35/// DC breaker type.
36#[derive(Debug, Clone, PartialEq)]
37pub enum DcBreakerType {
38    /// Mechanical breaker (slow, 30-80 ms).
39    Mechanical {
40        /// Opening time in milliseconds.
41        opening_time_ms: f64,
42    },
43    /// Solid-state breaker (fast, <1 ms, high losses).
44    SolidState {
45        /// Turn-off time in microseconds.
46        turn_off_time_us: f64,
47        /// On-state conduction loss percentage.
48        on_state_loss_pct: f64,
49    },
50    /// Hybrid breaker (mechanical + solid-state).
51    HybridBreaker {
52        /// Mechanical opening time in milliseconds.
53        opening_time_ms: f64,
54        /// Solid-state commutation time in microseconds.
55        commutation_time_us: f64,
56    },
57}
58
59/// DC fault type.
60#[derive(Debug, Clone, PartialEq)]
61pub enum DcFaultType {
62    /// Pole-to-ground fault.
63    PoleToGround,
64    /// Pole-to-pole fault.
65    PoleToPole,
66    /// Converter internal fault.
67    ConverterInternal,
68}
69
70/// Converter station parameters.
71#[derive(Debug, Clone)]
72pub struct ConverterStation {
73    /// Unique station identifier.
74    pub id: usize,
75    /// Station name.
76    pub name: String,
77    /// Converter topology.
78    pub topology: ConverterTopology,
79    /// Rated DC voltage (kV).
80    pub v_dc_rated_kv: f64,
81    /// Rated power (MW).
82    pub p_rated_mw: f64,
83    /// Transformer reactance (pu).
84    pub x_transformer_pu: f64,
85    /// AC system short-circuit ratio.
86    pub scr: f64,
87    /// Firing/modulation angle (degrees).
88    pub control_angle_deg: f64,
89    /// Commutation reactance (pu) — for LCC.
90    pub x_commutation_pu: f64,
91    /// Arm inductance (mH) — for MMC.
92    pub arm_inductance_mh: f64,
93    /// Submodule capacitance (μF) — for MMC.
94    pub sm_capacitance_uf: f64,
95}
96
97/// DC cable/line segment between two converter stations.
98#[derive(Debug, Clone)]
99pub struct DcCable {
100    /// Cable identifier.
101    pub id: usize,
102    /// Index of originating station.
103    pub from_station: usize,
104    /// Index of terminating station.
105    pub to_station: usize,
106    /// Resistance per km (Ω/km).
107    pub r_per_km: f64,
108    /// Inductance per km (mH/km).
109    pub l_per_km: f64,
110    /// Capacitance per km (μF/km).
111    pub c_per_km: f64,
112    /// Length in km.
113    pub length_km: f64,
114}
115
116/// DC fault event specification.
117#[derive(Debug, Clone)]
118pub struct DcFaultEvent {
119    /// Type of fault.
120    pub fault_type: DcFaultType,
121    /// Cable on which the fault occurs.
122    pub location_cable_id: usize,
123    /// Fractional distance from "from" terminal (0.0–1.0).
124    pub location_fraction: f64,
125    /// Fault resistance (Ω).
126    pub fault_resistance: f64,
127    /// Fault inception time (ms).
128    pub inception_time_ms: f64,
129}
130
131/// DC circuit breaker.
132#[derive(Debug, Clone)]
133pub struct DcBreaker {
134    /// Breaker identifier.
135    pub id: usize,
136    /// Station where the breaker is installed.
137    pub station_id: usize,
138    /// Breaker technology type.
139    pub breaker_type: DcBreakerType,
140    /// Maximum breaking current (kA).
141    pub max_breaking_current_ka: f64,
142    /// Energy absorption capacity (MJ).
143    pub energy_absorption_mj: f64,
144}
145
146/// Instantaneous DC transient state snapshot.
147#[derive(Debug, Clone)]
148pub struct DcTransientState {
149    /// Simulation time (ms).
150    pub time_ms: f64,
151    /// DC voltage at each station (kV).
152    pub station_voltages_kv: Vec<f64>,
153    /// DC current at each station (kA).
154    pub station_currents_ka: Vec<f64>,
155    /// Cable currents (kA).
156    pub cable_currents_ka: Vec<f64>,
157    /// Fault current (kA), zero if no fault active.
158    pub fault_current_ka: f64,
159    /// Breaker states (true = closed/conducting).
160    pub breaker_states: Vec<bool>,
161}
162
163/// Complete transient simulation result.
164#[derive(Debug, Clone)]
165pub struct DcTransientResult {
166    /// Time-series of transient states.
167    pub states: Vec<DcTransientState>,
168    /// Peak fault current observed (kA).
169    pub peak_fault_current_ka: f64,
170    /// Time at which fault was cleared (ms).
171    pub fault_clearing_time_ms: f64,
172    /// Total energy dissipated in fault/breaker (MJ).
173    pub energy_dissipated_mj: f64,
174    /// Time for voltage to recover to 90% of rated (ms).
175    pub voltage_recovery_time_ms: f64,
176    /// Maximum di/dt observed (kA/ms).
177    pub max_di_dt: f64,
178    /// Whether the fault was successfully cleared.
179    pub fault_cleared: bool,
180}
181
182/// Converter station steady-state operating point.
183#[derive(Debug, Clone)]
184pub struct ConverterOperatingPoint {
185    /// Station identifier.
186    pub station_id: usize,
187    /// DC power (MW).
188    pub p_dc_mw: f64,
189    /// DC voltage (kV).
190    pub v_dc_kv: f64,
191    /// DC current (kA).
192    pub i_dc_ka: f64,
193    /// Firing/modulation angle (degrees).
194    pub firing_angle_deg: f64,
195    /// Power factor.
196    pub power_factor: f64,
197    /// Converter losses (MW).
198    pub losses_mw: f64,
199}
200
201/// Steady-state DC grid power flow solution.
202#[derive(Debug, Clone)]
203pub struct DcGridSolution {
204    /// Operating point for each station.
205    pub operating_points: Vec<ConverterOperatingPoint>,
206    /// Losses in each cable (MW).
207    pub cable_losses_mw: Vec<f64>,
208    /// Total system losses (MW).
209    pub total_losses_mw: f64,
210    /// Whether the power flow converged.
211    pub converged: bool,
212    /// Number of Newton iterations used.
213    pub iterations: usize,
214}
215
216/// DC switching transient simulator for multi-terminal HVDC grids.
217///
218/// # Example
219/// ```
220/// use oxigrid::network::dc_switching::*;
221///
222/// let mut sim = DcSwitchingSimulator::new(10.0, 100.0);
223/// sim.add_station(ConverterStation {
224///     id: 0,
225///     name: "Rectifier".to_string(),
226///     topology: ConverterTopology::Lcc,
227///     v_dc_rated_kv: 500.0,
228///     p_rated_mw: 1000.0,
229///     x_transformer_pu: 0.15,
230///     scr: 3.0,
231///     control_angle_deg: 15.0,
232///     x_commutation_pu: 0.1,
233///     arm_inductance_mh: 0.0,
234///     sm_capacitance_uf: 0.0,
235/// });
236/// ```
237#[derive(Debug, Clone)]
238pub struct DcSwitchingSimulator {
239    /// Converter stations.
240    pub stations: Vec<ConverterStation>,
241    /// DC cables connecting stations.
242    pub cables: Vec<DcCable>,
243    /// DC circuit breakers.
244    pub breakers: Vec<DcBreaker>,
245    /// Simulation time step (μs).
246    pub dt_us: f64,
247    /// Total simulation duration (ms).
248    pub duration_ms: f64,
249}
250
251// ─────────────────────────────────────────────────────────────────────────────
252// Implementation
253// ─────────────────────────────────────────────────────────────────────────────
254
255impl DcSwitchingSimulator {
256    /// Create a new simulator with the given time step and duration.
257    ///
258    /// # Arguments
259    /// * `dt_us` — Integration time step in microseconds.
260    /// * `duration_ms` — Total simulation duration in milliseconds.
261    pub fn new(dt_us: f64, duration_ms: f64) -> Self {
262        Self {
263            stations: Vec::new(),
264            cables: Vec::new(),
265            breakers: Vec::new(),
266            dt_us: dt_us.max(0.1),
267            duration_ms: duration_ms.max(0.001),
268        }
269    }
270
271    /// Add a converter station to the system.
272    pub fn add_station(&mut self, station: ConverterStation) {
273        self.stations.push(station);
274    }
275
276    /// Add a DC cable to the system.
277    pub fn add_cable(&mut self, cable: DcCable) {
278        self.cables.push(cable);
279    }
280
281    /// Add a DC breaker to the system.
282    pub fn add_breaker(&mut self, breaker: DcBreaker) {
283        self.breakers.push(breaker);
284    }
285
286    // ── DC Power Flow (Newton's method) ──────────────────────────────────
287
288    /// Solve the DC grid power flow using Newton's method.
289    ///
290    /// Computes steady-state voltages, currents, and losses across the
291    /// multi-terminal DC grid. The first station is treated as the voltage
292    /// reference (slack bus).
293    pub fn solve_dc_power_flow(&self) -> Result<DcGridSolution, String> {
294        let n = self.stations.len();
295        if n == 0 {
296            return Err("No converter stations defined".to_string());
297        }
298
299        // Build conductance matrix G (n×n dense, stored row-major)
300        let mut g_matrix = vec![0.0_f64; n * n];
301        for cable in &self.cables {
302            let from = cable.from_station;
303            let to = cable.to_station;
304            if from >= n || to >= n {
305                continue;
306            }
307            let r_total = (cable.r_per_km * cable.length_km).max(1e-9);
308            let g = 1.0 / r_total;
309            g_matrix[from * n + from] += g;
310            g_matrix[to * n + to] += g;
311            g_matrix[from * n + to] -= g;
312            g_matrix[to * n + from] -= g;
313        }
314
315        // Initial voltages: rated DC voltage for each station
316        let mut v: Vec<f64> = self.stations.iter().map(|s| s.v_dc_rated_kv).collect();
317
318        // Power specification: positive = injecting, negative = absorbing
319        // Station 0 is slack (voltage-controlled), others are power-controlled
320        let p_spec: Vec<f64> = self
321            .stations
322            .iter()
323            .enumerate()
324            .map(|(i, s)| {
325                if i == 0 {
326                    0.0 // slack — will be computed
327                } else {
328                    // Inverter stations absorb power (negative sign convention)
329                    -s.p_rated_mw * 0.8 // operate at 80% of rated
330                }
331            })
332            .collect();
333
334        let max_iter = 50;
335        let tol = 1e-6;
336        let mut converged = false;
337        let mut iterations = 0;
338
339        for iter in 0..max_iter {
340            iterations = iter + 1;
341
342            // Compute P_calc = V_i * sum_j(G_ij * V_j)
343            let mut p_calc = vec![0.0_f64; n];
344            for i in 0..n {
345                let mut sum = 0.0;
346                for j in 0..n {
347                    sum += g_matrix[i * n + j] * v[j];
348                }
349                p_calc[i] = v[i] * sum;
350            }
351
352            // Mismatch: ΔP = P_spec - P_calc (skip slack bus 0)
353            let mut max_mismatch = 0.0_f64;
354            let mut dp = vec![0.0_f64; n];
355            for i in 1..n {
356                dp[i] = p_spec[i] - p_calc[i];
357                max_mismatch = max_mismatch.max(dp[i].abs());
358            }
359
360            if max_mismatch < tol {
361                converged = true;
362                break;
363            }
364
365            // Jacobian J(i,j) = dP_i/dV_j for i,j >= 1
366            // dP_i/dV_j = G_ij * V_i (j != i)
367            // dP_i/dV_i = 2*G_ii*V_i + sum_{j!=i}(G_ij*V_j)
368            if n == 2 {
369                // Simple 2-bus case: direct update
370                let j11 = 2.0 * g_matrix[n + 1] * v[1] + g_matrix[n] * v[0];
371                if j11.abs() > 1e-12 {
372                    v[1] += dp[1] / j11;
373                }
374            } else {
375                // General case: solve J * ΔV = ΔP for buses 1..n-1
376                // Using simple Gauss-Seidel-like update for robustness
377                for i in 1..n {
378                    let j_ii = 2.0 * g_matrix[i * n + i] * v[i]
379                        + (0..n)
380                            .filter(|&j| j != i)
381                            .map(|j| g_matrix[i * n + j] * v[j])
382                            .sum::<f64>();
383                    if j_ii.abs() > 1e-12 {
384                        v[i] += dp[i] / j_ii;
385                    }
386                    // Clamp voltage to reasonable range
387                    let v_rated = self.stations[i].v_dc_rated_kv;
388                    v[i] = v[i].clamp(v_rated * 0.5, v_rated * 1.5);
389                }
390            }
391        }
392
393        // Compute operating points and cable losses
394        let mut operating_points = Vec::with_capacity(n);
395        for (i, station) in self.stations.iter().enumerate() {
396            let v_kv = v[i];
397            // Current from power balance
398            let mut i_sum = 0.0;
399            for j in 0..n {
400                i_sum += g_matrix[i * n + j] * v[j];
401            }
402            let i_ka = i_sum; // current in kA (since V in kV, G in 1/kΩ→ I in kA)
403            let p_mw = v_kv * i_ka;
404            let op = match &station.topology {
405                ConverterTopology::Lcc => self.lcc_operating_point(station, i_ka.abs()),
406                _ => self.vsc_operating_point(station, p_mw.abs(), v_kv),
407            };
408            operating_points.push(ConverterOperatingPoint {
409                station_id: station.id,
410                p_dc_mw: p_mw,
411                v_dc_kv: v_kv,
412                i_dc_ka: i_ka,
413                firing_angle_deg: op.firing_angle_deg,
414                power_factor: op.power_factor,
415                losses_mw: op.losses_mw,
416            });
417        }
418
419        let mut cable_losses_mw = Vec::with_capacity(self.cables.len());
420        let mut total_losses = 0.0;
421        for cable in &self.cables {
422            let from = cable.from_station.min(n - 1);
423            let to = cable.to_station.min(n - 1);
424            let r_total = (cable.r_per_km * cable.length_km).max(1e-9);
425            let dv = v[from] - v[to];
426            let i_cable = dv / r_total; // kA
427            let loss = i_cable * i_cable * r_total; // kA² * kΩ = MW
428            cable_losses_mw.push(loss.abs());
429            total_losses += loss.abs();
430        }
431
432        // Add converter losses
433        for op in &operating_points {
434            total_losses += op.losses_mw;
435        }
436
437        Ok(DcGridSolution {
438            operating_points,
439            cable_losses_mw,
440            total_losses_mw: total_losses,
441            converged,
442            iterations,
443        })
444    }
445
446    // ── Converter operating points ───────────────────────────────────────
447
448    /// Compute LCC converter operating point.
449    ///
450    /// Uses the standard LCC equations:
451    /// - `V_dc = (3√2/π) · V_ac · cos(α) − (3/π) · X_c · I_dc`
452    /// - Power factor ≈ cos(α) · (1 − μ/2)
453    /// - Losses = 0.7% × P_rated + I_dc² × R_converter
454    pub fn lcc_operating_point(
455        &self,
456        station: &ConverterStation,
457        i_dc_ka: f64,
458    ) -> ConverterOperatingPoint {
459        let alpha_rad = station.control_angle_deg.to_radians();
460        // AC voltage from SCR: V_ac ≈ V_dc_rated * π / (3√2)  (at nominal)
461        let v_ac_kv = station.v_dc_rated_kv * PI / (3.0 * SQRT_2);
462
463        // No-load DC voltage
464        let v_d0 = (3.0 * SQRT_2 / PI) * v_ac_kv;
465
466        // Commutation reactance drop
467        // X_c in kΩ (from pu): X_c_pu * Z_base where Z_base = V²/P
468        let z_base = station.v_dc_rated_kv.powi(2) / station.p_rated_mw.max(1e-6);
469        let x_c = station.x_commutation_pu * z_base;
470
471        let v_dc = v_d0 * alpha_rad.cos() - (3.0 / PI) * x_c * i_dc_ka;
472        let p_dc = v_dc * i_dc_ka;
473
474        // Overlap angle approximation
475        let cos_alpha = alpha_rad.cos();
476        let mu_arg = (cos_alpha
477            - 2.0 * station.x_commutation_pu * i_dc_ka / station.p_rated_mw.max(1e-6)
478                * station.v_dc_rated_kv)
479            .clamp(-1.0, 1.0);
480        let mu_rad = (alpha_rad.cos() - mu_arg).abs().min(PI / 4.0);
481        let pf = cos_alpha * (1.0 - mu_rad / 2.0);
482
483        // Losses: 0.7% of rated + I²R
484        let r_conv = 0.001 * z_base; // ~0.1% impedance
485        let losses = 0.007 * station.p_rated_mw + i_dc_ka * i_dc_ka * r_conv;
486
487        ConverterOperatingPoint {
488            station_id: station.id,
489            p_dc_mw: p_dc,
490            v_dc_kv: v_dc,
491            i_dc_ka,
492            firing_angle_deg: station.control_angle_deg,
493            power_factor: pf.clamp(0.0, 1.0),
494            losses_mw: losses.abs(),
495        }
496    }
497
498    /// Compute VSC converter operating point.
499    ///
500    /// Uses VSC equations:
501    /// - `V_dc = V_ac · m · √(3/2)` where m = modulation index
502    /// - Independent P/Q control capability
503    /// - Losses = a + b·I + c·I² (switching + conduction)
504    pub fn vsc_operating_point(
505        &self,
506        station: &ConverterStation,
507        p_mw: f64,
508        v_dc_kv: f64,
509    ) -> ConverterOperatingPoint {
510        let v_dc = v_dc_kv.max(1e-6);
511        let i_dc_ka = p_mw / v_dc;
512
513        // Modulation index (back-calculated)
514        let v_ac_kv = v_dc / (1.5_f64).sqrt();
515        let m = if v_ac_kv > 1e-6 {
516            (v_dc / (v_ac_kv * (1.5_f64).sqrt())).clamp(0.0, 1.15)
517        } else {
518            0.85
519        };
520
521        // VSC losses: a + b*I + c*I²
522        // Typical: a=0.5% P_rated (no-load), b=0.1% P_rated/I_rated, c=0.5% P_rated/I_rated²
523        let i_rated = station.p_rated_mw / station.v_dc_rated_kv.max(1e-6);
524        let a = 0.005 * station.p_rated_mw;
525        let b = 0.001 * station.p_rated_mw / i_rated.max(1e-6);
526        let c = 0.005 * station.p_rated_mw / (i_rated * i_rated).max(1e-12);
527        let losses = a + b * i_dc_ka.abs() + c * i_dc_ka * i_dc_ka;
528
529        // MMC has lower losses due to reduced switching frequency per device
530        let loss_factor = match &station.topology {
531            ConverterTopology::Mmc { n_submodules } => {
532                let nsm = (*n_submodules).max(1) as f64;
533                0.5 + 0.5 / nsm.sqrt() // lower losses with more SMs
534            }
535            ConverterTopology::TwoLevelVsc => 1.0,
536            ConverterTopology::Hybrid => 0.8,
537            ConverterTopology::Lcc => 0.6, // LCC has lower switching losses
538        };
539
540        let pf = 1.0; // VSC can operate at unity PF
541
542        ConverterOperatingPoint {
543            station_id: station.id,
544            p_dc_mw: p_mw,
545            v_dc_kv: v_dc,
546            i_dc_ka,
547            firing_angle_deg: m * 180.0 / PI, // modulation index as angle equivalent
548            power_factor: pf,
549            losses_mw: (losses * loss_factor).abs(),
550        }
551    }
552
553    // ── Fault simulation ──────────────────────────────────────────────────
554
555    /// Simulate a DC fault event and return the transient result.
556    ///
557    /// Models the fault as an RLC circuit with parameters derived from the
558    /// faulted cable and connected converter stations. Uses RK4 integration
559    /// for the state-space model.
560    pub fn simulate_fault(&self, fault: &DcFaultEvent) -> Result<DcTransientResult, String> {
561        if self.stations.is_empty() {
562            return Err("No converter stations defined".to_string());
563        }
564        if self.cables.is_empty() {
565            return Err("No cables defined".to_string());
566        }
567
568        let n_stations = self.stations.len();
569        let n_cables = self.cables.len();
570        let n_breakers = self.breakers.len();
571
572        let dt_ms = self.dt_us / 1000.0; // time step in ms
573        let total_steps = ((self.duration_ms / dt_ms).ceil() as usize).max(1);
574
575        // Extract fault circuit parameters
576        let (l_total_mh, c_total_uf, r_total_ohm, r_fault, v_dc_kv) = self.fault_rlc_params(fault);
577
578        // Convert to base units for RLC computation
579        let l_h = l_total_mh * 1e-3;
580        let c_f = c_total_uf * 1e-6;
581        let v_dc_v = v_dc_kv * 1e3;
582
583        // RLC natural response parameters
584        let alpha = r_total_ohm / (2.0 * l_h.max(1e-9));
585        let omega0_sq = 1.0 / (l_h.max(1e-9) * c_f.max(1e-12));
586        let is_underdamped = alpha * alpha < omega0_sq;
587
588        // Initialize state
589        let mut state = DcTransientState {
590            time_ms: 0.0,
591            station_voltages_kv: self.stations.iter().map(|s| s.v_dc_rated_kv).collect(),
592            station_currents_ka: vec![0.0; n_stations],
593            cable_currents_ka: vec![0.0; n_cables],
594            fault_current_ka: 0.0,
595            breaker_states: vec![true; n_breakers],
596        };
597
598        let mut states = Vec::with_capacity(total_steps.min(10000));
599        let mut peak_fault_ka = 0.0_f64;
600        let mut max_di_dt = 0.0_f64;
601        let mut energy_mj = 0.0_f64;
602        let mut fault_cleared = false;
603        let mut clearing_time_ms = self.duration_ms;
604        let mut voltage_recovery_time_ms = self.duration_ms;
605        let mut prev_fault_current_ka = 0.0_f64;
606        let mut fault_active = false;
607
608        // Determine breaker clearing time
609        let breaker_clear_ms = self.fastest_breaker_clearing_ms();
610
611        // Determine recording interval (keep states manageable)
612        let record_interval = (total_steps / 5000).max(1);
613
614        for step in 0..total_steps {
615            let t_ms = step as f64 * dt_ms;
616
617            // Check fault inception
618            if !fault_active && t_ms >= fault.inception_time_ms {
619                fault_active = true;
620            }
621
622            if fault_active && !fault_cleared {
623                // RK4 step for fault current
624                let dt_s = dt_ms * 1e-3;
625                let new_state = self.rk4_step(&state, dt_s, Some(fault));
626
627                // Compute fault current from RLC model
628                let t_fault_s = (t_ms - fault.inception_time_ms).max(0.0) * 1e-3;
629
630                let i_fault_a = if is_underdamped {
631                    let omega_d = (omega0_sq - alpha * alpha).max(0.0).sqrt();
632                    if omega_d > 1e-12 {
633                        (v_dc_v / (omega_d * l_h.max(1e-9)))
634                            * (-alpha * t_fault_s).exp()
635                            * (omega_d * t_fault_s).sin()
636                    } else {
637                        0.0
638                    }
639                } else {
640                    // Overdamped or critically damped
641                    let disc = (alpha * alpha - omega0_sq).max(0.0).sqrt();
642                    let s1 = -alpha + disc;
643                    let s2 = -alpha - disc;
644                    if (s1 - s2).abs() > 1e-12 {
645                        let a_coeff = v_dc_v / (l_h.max(1e-9) * (s1 - s2));
646                        a_coeff * ((s1 * t_fault_s).exp() - (s2 * t_fault_s).exp())
647                    } else {
648                        // Critically damped
649                        (v_dc_v / l_h.max(1e-9)) * t_fault_s * (-alpha * t_fault_s).exp()
650                    }
651                };
652
653                let i_fault_ka = i_fault_a.abs() / 1e3;
654
655                // Apply fault resistance effect
656                let r_factor = 1.0 / (1.0 + r_fault / (r_total_ohm + 1e-9));
657                let i_fault_ka = i_fault_ka * r_factor;
658
659                // Pole-to-ground has lower current than pole-to-pole
660                let fault_type_factor = match fault.fault_type {
661                    DcFaultType::PoleToGround => 0.5,
662                    DcFaultType::PoleToPole => 1.0,
663                    DcFaultType::ConverterInternal => 0.7,
664                };
665                let i_fault_ka = i_fault_ka * fault_type_factor;
666
667                state.fault_current_ka = i_fault_ka;
668
669                // Update cable currents from new_state
670                state.cable_currents_ka = new_state.cable_currents_ka;
671                state.station_voltages_kv = new_state.station_voltages_kv;
672                state.station_currents_ka = new_state.station_currents_ka;
673
674                // Track di/dt
675                let di_dt = (i_fault_ka - prev_fault_current_ka).abs() / dt_ms.max(1e-12);
676                max_di_dt = max_di_dt.max(di_dt);
677                prev_fault_current_ka = i_fault_ka;
678
679                // Track peak
680                peak_fault_ka = peak_fault_ka.max(i_fault_ka);
681
682                // Energy dissipated: I²R·dt
683                let i_a = i_fault_ka * 1e3;
684                energy_mj += i_a * i_a * (r_fault + r_total_ohm) * (dt_ms * 1e-3) / 1e6;
685
686                // Check breaker clearing
687                let t_since_inception = t_ms - fault.inception_time_ms;
688                if t_since_inception >= breaker_clear_ms && !fault_cleared {
689                    fault_cleared = true;
690                    clearing_time_ms = t_ms;
691                    // Open all breakers
692                    for b in state.breaker_states.iter_mut() {
693                        *b = false;
694                    }
695                }
696            } else if fault_cleared {
697                // Post-clearing: exponential decay
698                let t_after_clear_s = (t_ms - clearing_time_ms).max(0.0) * 1e-3;
699                let decay_tau = l_h / r_total_ohm.max(1e-9);
700                state.fault_current_ka =
701                    prev_fault_current_ka * (-t_after_clear_s / decay_tau.max(1e-9)).exp();
702                if state.fault_current_ka < 1e-6 {
703                    state.fault_current_ka = 0.0;
704                }
705
706                // Voltage recovery check
707                let all_recovered = state
708                    .station_voltages_kv
709                    .iter()
710                    .enumerate()
711                    .all(|(i, &v)| v >= 0.9 * self.stations[i].v_dc_rated_kv);
712                if all_recovered && voltage_recovery_time_ms >= self.duration_ms {
713                    voltage_recovery_time_ms = t_ms;
714                }
715
716                // Recover voltages gradually
717                for (i, v) in state.station_voltages_kv.iter_mut().enumerate() {
718                    let v_rated = self.stations[i].v_dc_rated_kv;
719                    let recovery_rate = 0.001 * v_rated; // kV per step
720                    if *v < v_rated {
721                        *v = (*v + recovery_rate).min(v_rated);
722                    }
723                }
724            }
725
726            state.time_ms = t_ms;
727
728            // Record state at intervals
729            if step % record_interval == 0 || step == total_steps - 1 {
730                states.push(state.clone());
731            }
732        }
733
734        Ok(DcTransientResult {
735            states,
736            peak_fault_current_ka: peak_fault_ka,
737            fault_clearing_time_ms: if fault_cleared {
738                clearing_time_ms
739            } else {
740                self.duration_ms
741            },
742            energy_dissipated_mj: energy_mj,
743            voltage_recovery_time_ms,
744            max_di_dt,
745            fault_cleared,
746        })
747    }
748
749    /// Simulate a switching transient (breaker open/close).
750    ///
751    /// Models the voltage/current transients that occur when a breaker
752    /// is opened or closed, including LC oscillations from cable capacitance.
753    pub fn simulate_switching(
754        &self,
755        breaker_id: usize,
756        open: bool,
757    ) -> Result<DcTransientResult, String> {
758        if self.stations.is_empty() {
759            return Err("No converter stations defined".to_string());
760        }
761
762        let n_stations = self.stations.len();
763        let _n_cables = self.cables.len();
764        let n_breakers = self.breakers.len();
765
766        let dt_ms = self.dt_us / 1000.0;
767        let total_steps = ((self.duration_ms / dt_ms).ceil() as usize).max(1);
768
769        let mut state = DcTransientState {
770            time_ms: 0.0,
771            station_voltages_kv: self.stations.iter().map(|s| s.v_dc_rated_kv).collect(),
772            station_currents_ka: vec![0.0; n_stations],
773            cable_currents_ka: self
774                .cables
775                .iter()
776                .map(|c| {
777                    let r = (c.r_per_km * c.length_km).max(1e-9);
778                    let from = c.from_station.min(n_stations.saturating_sub(1));
779                    let to = c.to_station.min(n_stations.saturating_sub(1));
780                    let v_from = self.stations.get(from).map_or(0.0, |s| s.v_dc_rated_kv);
781                    let v_to = self.stations.get(to).map_or(0.0, |s| s.v_dc_rated_kv);
782                    (v_from - v_to) / r
783                })
784                .collect(),
785            fault_current_ka: 0.0,
786            breaker_states: vec![!open; n_breakers],
787        };
788
789        let mut states = Vec::with_capacity(total_steps.min(10000));
790        let record_interval = (total_steps / 5000).max(1);
791        let switching_time_ms = self.duration_ms * 0.1; // switch at 10% of duration
792        let mut switched = false;
793
794        for step in 0..total_steps {
795            let t_ms = step as f64 * dt_ms;
796            state.time_ms = t_ms;
797
798            // Perform switching event
799            if !switched && t_ms >= switching_time_ms {
800                switched = true;
801                for (i, b) in state.breaker_states.iter_mut().enumerate() {
802                    if i == breaker_id || breaker_id >= n_breakers {
803                        *b = !open;
804                    }
805                }
806                if open {
807                    // Opening: force cable currents to decay (interrupted)
808                    for i_c in state.cable_currents_ka.iter_mut() {
809                        *i_c *= 0.5; // initial step reduction
810                    }
811                }
812            }
813
814            // RK4 step for normal operation (no fault)
815            let dt_s = dt_ms * 1e-3;
816            let new_state = self.rk4_step(&state, dt_s, None);
817            state.cable_currents_ka = new_state.cable_currents_ka;
818            state.station_voltages_kv = new_state.station_voltages_kv;
819            state.station_currents_ka = new_state.station_currents_ka;
820
821            if step % record_interval == 0 || step == total_steps - 1 {
822                states.push(state.clone());
823            }
824        }
825
826        Ok(DcTransientResult {
827            states,
828            peak_fault_current_ka: 0.0,
829            fault_clearing_time_ms: 0.0,
830            energy_dissipated_mj: 0.0,
831            voltage_recovery_time_ms: 0.0,
832            max_di_dt: 0.0,
833            fault_cleared: true,
834        })
835    }
836
837    // ── RK4 integration step ──────────────────────────────────────────────
838
839    /// Perform one RK4 integration step on the state-space model.
840    ///
841    /// State vector: `[V_dc_1..N, I_cable_1..M]`
842    /// ```text
843    /// dI_cable/dt = (V_from − V_to − R·I) / L
844    /// dV_node/dt  = (I_in − I_out − I_load) / C
845    /// ```
846    fn rk4_step(
847        &self,
848        state: &DcTransientState,
849        dt: f64,
850        fault: Option<&DcFaultEvent>,
851    ) -> DcTransientState {
852        let n_s = self.stations.len();
853        let n_c = self.cables.len();
854
855        // Pack state into flat vector: [V_0..V_{n-1}, I_0..I_{m-1}]
856        let state_len = n_s + n_c;
857        let mut y = vec![0.0_f64; state_len];
858        for (i, &v) in state.station_voltages_kv.iter().enumerate() {
859            if i < n_s {
860                y[i] = v;
861            }
862        }
863        for (i, &ic) in state.cable_currents_ka.iter().enumerate() {
864            if i < n_c {
865                y[n_s + i] = ic;
866            }
867        }
868
869        // RK4 stages
870        let k1 = self.state_derivatives(&y, fault);
871        let y2: Vec<f64> = y
872            .iter()
873            .zip(k1.iter())
874            .map(|(&yi, &ki)| yi + 0.5 * dt * ki)
875            .collect();
876        let k2 = self.state_derivatives(&y2, fault);
877        let y3: Vec<f64> = y
878            .iter()
879            .zip(k2.iter())
880            .map(|(&yi, &ki)| yi + 0.5 * dt * ki)
881            .collect();
882        let k3 = self.state_derivatives(&y3, fault);
883        let y4: Vec<f64> = y
884            .iter()
885            .zip(k3.iter())
886            .map(|(&yi, &ki)| yi + dt * ki)
887            .collect();
888        let k4 = self.state_derivatives(&y4, fault);
889
890        // Combine: y_new = y + (dt/6)*(k1 + 2*k2 + 2*k3 + k4)
891        let y_new: Vec<f64> = (0..state_len)
892            .map(|i| y[i] + (dt / 6.0) * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]))
893            .collect();
894
895        // Unpack
896        let mut new_state = state.clone();
897        for (i, v) in y_new.iter().take(n_s).enumerate() {
898            new_state.station_voltages_kv[i] = v.max(0.0);
899        }
900        new_state.cable_currents_ka[..n_c].copy_from_slice(&y_new[n_s..n_s + n_c]);
901
902        // Update station currents from cable currents
903        for i in 0..n_s {
904            let mut i_net = 0.0;
905            for (ci, cable) in self.cables.iter().enumerate() {
906                let i_cable = if ci < n_c { y_new[n_s + ci] } else { 0.0 };
907                if cable.to_station == i {
908                    i_net += i_cable;
909                } else if cable.from_station == i {
910                    i_net -= i_cable;
911                }
912            }
913            if i < new_state.station_currents_ka.len() {
914                new_state.station_currents_ka[i] = i_net;
915            }
916        }
917
918        new_state
919    }
920
921    /// Compute state derivatives for the DC grid state-space model.
922    ///
923    /// Returns dy/dt for the packed state vector `[V_0..V_{n-1}, I_0..I_{m-1}]`.
924    fn state_derivatives(&self, y: &[f64], fault: Option<&DcFaultEvent>) -> Vec<f64> {
925        let n_s = self.stations.len();
926        let n_c = self.cables.len();
927        let mut dydt = vec![0.0_f64; n_s + n_c];
928
929        // Cable current derivatives: dI/dt = (V_from - V_to - R*I) / L
930        for (ci, cable) in self.cables.iter().enumerate() {
931            let from = cable.from_station.min(n_s.saturating_sub(1));
932            let to = cable.to_station.min(n_s.saturating_sub(1));
933            let v_from = if from < n_s { y[from] } else { 0.0 };
934            let v_to = if to < n_s { y[to] } else { 0.0 };
935            let r_ohm = (cable.r_per_km * cable.length_km).max(1e-9); // Ω
936            let l_h = (cable.l_per_km * cable.length_km * 1e-3).max(1e-9); // H (from mH)
937            let i_ka = if (n_s + ci) < y.len() {
938                y[n_s + ci]
939            } else {
940                0.0
941            };
942            // V in kV, I in kA, R in Ω → need unit consistency
943            // dI(kA)/dt(s) = (V(kV)*1e3 - R(Ω)*I(kA)*1e3) / (L(H)*1e3)
944            // = (V(kV) - R(Ω)*I(kA)) * 1e3 / (L(H)*1e3)  = (V(kV) - R*I(kA)) / L(H)
945            // Actually: V=kV→V*1e3 in volts, I=kA→I*1e3 in amps, result in A/s, convert back to kA/s
946            // dI[A/s] = (V[V] - R[Ω]*I[A]) / L[H]
947            // dI[kA/s] = (V[kV]*1e3 - R[Ω]*I[kA]*1e3) / (L[H]*1e3) = (V[kV] - R[Ω]*I[kA]) / L[H]
948            dydt[n_s + ci] = (v_from - v_to - r_ohm * i_ka) / l_h;
949        }
950
951        // Station voltage derivatives: dV/dt = (I_in - I_out - I_load) / C
952        for (si, station) in self.stations.iter().enumerate() {
953            // Capacitance: MMC submodule capacitance or cable capacitance
954            let c_uf = self.station_capacitance_uf(station);
955            let c_f = (c_uf * 1e-6).max(1e-12); // Farads
956
957            let mut i_net_ka = 0.0;
958            for (ci, cable) in self.cables.iter().enumerate() {
959                let i_cable = if (n_s + ci) < y.len() {
960                    y[n_s + ci]
961                } else {
962                    0.0
963                };
964                if cable.to_station == si {
965                    i_net_ka += i_cable;
966                } else if cable.from_station == si {
967                    i_net_ka -= i_cable;
968                }
969            }
970
971            // Fault current drain at faulted cable's connected station
972            if let Some(f) = fault {
973                if f.location_cable_id < self.cables.len() {
974                    let cable = &self.cables[f.location_cable_id];
975                    if cable.from_station == si || cable.to_station == si {
976                        // Fault drains current from this station
977                        let v_kv = if si < y.len() { y[si] } else { 0.0 };
978                        let r_f = f.fault_resistance.max(1e-6);
979                        let i_fault_ka = v_kv * 1e3 / (r_f * 1e3); // kV*1e3/Ω / 1e3 = kA
980                        i_net_ka -= i_fault_ka * f.location_fraction;
981                    }
982                }
983            }
984
985            // dV[kV/s] = I[kA]*1e3 / (C[F]*1e3) = I[kA]/C[F]
986            // Actually: dV[V/s] = I[A]/C[F], dV[kV/s] = I[kA]/C[F]  (both ×1e3 cancel)
987            dydt[si] = i_net_ka / c_f;
988        }
989
990        dydt
991    }
992
993    /// Detect whether a fault condition exists based on state changes.
994    ///
995    /// Returns `true` if |di/dt| exceeds a threshold or voltage drops
996    /// below 80% of rated.
997    #[allow(dead_code)]
998    fn detect_fault(&self, state: &DcTransientState, prev: &DcTransientState) -> bool {
999        let dt_ms = (state.time_ms - prev.time_ms).abs().max(1e-9);
1000
1001        // Check di/dt on cable currents
1002        for (i, &i_now) in state.cable_currents_ka.iter().enumerate() {
1003            let i_prev = prev.cable_currents_ka.get(i).copied().unwrap_or(0.0);
1004            let di_dt = (i_now - i_prev).abs() / dt_ms;
1005            if di_dt > 10.0 {
1006                // threshold: 10 kA/ms
1007                return true;
1008            }
1009        }
1010
1011        // Check voltage drop
1012        for (i, &v) in state.station_voltages_kv.iter().enumerate() {
1013            let v_rated = self.stations.get(i).map_or(500.0, |s| s.v_dc_rated_kv);
1014            if v < 0.8 * v_rated {
1015                return true;
1016            }
1017        }
1018
1019        false
1020    }
1021
1022    // ── Private helpers ───────────────────────────────────────────────────
1023
1024    /// Compute aggregate RLC parameters for the fault circuit.
1025    ///
1026    /// Returns `(L_total_mH, C_total_uF, R_total_Ohm, R_fault_Ohm, V_dc_kV)`.
1027    fn fault_rlc_params(&self, fault: &DcFaultEvent) -> (f64, f64, f64, f64, f64) {
1028        let cable_id = fault
1029            .location_cable_id
1030            .min(self.cables.len().saturating_sub(1));
1031        let cable = &self.cables[cable_id];
1032        let frac = fault.location_fraction.clamp(0.0, 1.0);
1033        let dist = cable.length_km * frac;
1034
1035        // Cable impedance up to fault point
1036        let r_cable = cable.r_per_km * dist;
1037        let l_cable = cable.l_per_km * dist; // mH
1038        let c_cable = cable.c_per_km * cable.length_km; // μF (full cable)
1039
1040        // Converter arm inductance (MMC)
1041        let from = cable
1042            .from_station
1043            .min(self.stations.len().saturating_sub(1));
1044        let l_arm = self.stations.get(from).map_or(0.0, |s| s.arm_inductance_mh);
1045
1046        // Total
1047        let l_total = l_cable + l_arm + 1.0; // add 1 mH minimum smoothing
1048        let c_total = c_cable
1049            + self
1050                .stations
1051                .iter()
1052                .map(|s| s.sm_capacitance_uf)
1053                .sum::<f64>();
1054        let c_total = c_total.max(0.01); // minimum 0.01 μF
1055
1056        let v_dc = self.stations.get(from).map_or(500.0, |s| s.v_dc_rated_kv);
1057
1058        (
1059            l_total,
1060            c_total,
1061            r_cable.max(0.01),
1062            fault.fault_resistance,
1063            v_dc,
1064        )
1065    }
1066
1067    /// Compute effective capacitance at a station (μF).
1068    fn station_capacitance_uf(&self, station: &ConverterStation) -> f64 {
1069        let base = match &station.topology {
1070            ConverterTopology::Mmc { n_submodules } => {
1071                // MMC: total arm capacitance = C_sm / (6 * N_sm) per phase, simplified
1072                let nsm = (*n_submodules).max(1) as f64;
1073                station.sm_capacitance_uf * nsm / 6.0
1074            }
1075            _ => {
1076                // VSC DC-link or LCC smoothing: estimate from rated values
1077                // C = P / (V² * ω) as rough sizing, expressed in μF
1078                let v = station.v_dc_rated_kv.max(1.0);
1079                let p = station.p_rated_mw.max(1.0);
1080                (p / (v * v * 314.0)) * 1e6 // μF
1081            }
1082        };
1083        // Also add connected cable capacitance
1084        let cable_c: f64 = self
1085            .cables
1086            .iter()
1087            .filter(|c| c.from_station == station.id || c.to_station == station.id)
1088            .map(|c| c.c_per_km * c.length_km * 0.5) // half of cable C at each end
1089            .sum();
1090        (base + cable_c).max(0.01)
1091    }
1092
1093    /// Return the fastest breaker clearing time in ms.
1094    fn fastest_breaker_clearing_ms(&self) -> f64 {
1095        if self.breakers.is_empty() {
1096            return self.duration_ms;
1097        }
1098        self.breakers
1099            .iter()
1100            .map(|b| breaker_clearing_time_ms(&b.breaker_type))
1101            .fold(f64::MAX, f64::min)
1102    }
1103}
1104
1105/// Compute the effective clearing time (ms) for a given breaker type.
1106fn breaker_clearing_time_ms(bt: &DcBreakerType) -> f64 {
1107    match bt {
1108        DcBreakerType::Mechanical { opening_time_ms } => *opening_time_ms,
1109        DcBreakerType::SolidState {
1110            turn_off_time_us, ..
1111        } => turn_off_time_us / 1000.0,
1112        DcBreakerType::HybridBreaker {
1113            opening_time_ms,
1114            commutation_time_us,
1115        } => {
1116            // Hybrid: solid-state commutates first, then mechanical opens
1117            commutation_time_us / 1000.0 + opening_time_ms * 0.3
1118        }
1119    }
1120}
1121
1122// ─────────────────────────────────────────────────────────────────────────────
1123// Tests
1124// ─────────────────────────────────────────────────────────────────────────────
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129
1130    // ── Helpers ──────────────────────────────────────────────────────────
1131
1132    fn lcc_station(id: usize, alpha_deg: f64) -> ConverterStation {
1133        ConverterStation {
1134            id,
1135            name: format!("LCC-{id}"),
1136            topology: ConverterTopology::Lcc,
1137            v_dc_rated_kv: 500.0,
1138            p_rated_mw: 1000.0,
1139            x_transformer_pu: 0.15,
1140            scr: 3.0,
1141            control_angle_deg: alpha_deg,
1142            x_commutation_pu: 0.1,
1143            arm_inductance_mh: 0.0,
1144            sm_capacitance_uf: 0.0,
1145        }
1146    }
1147
1148    fn vsc_station(id: usize) -> ConverterStation {
1149        ConverterStation {
1150            id,
1151            name: format!("VSC-{id}"),
1152            topology: ConverterTopology::TwoLevelVsc,
1153            v_dc_rated_kv: 320.0,
1154            p_rated_mw: 800.0,
1155            x_transformer_pu: 0.12,
1156            scr: 5.0,
1157            control_angle_deg: 0.0,
1158            x_commutation_pu: 0.0,
1159            arm_inductance_mh: 0.0,
1160            sm_capacitance_uf: 0.0,
1161        }
1162    }
1163
1164    fn mmc_station(id: usize) -> ConverterStation {
1165        ConverterStation {
1166            id,
1167            name: format!("MMC-{id}"),
1168            topology: ConverterTopology::Mmc { n_submodules: 400 },
1169            v_dc_rated_kv: 320.0,
1170            p_rated_mw: 1000.0,
1171            x_transformer_pu: 0.12,
1172            scr: 5.0,
1173            control_angle_deg: 0.0,
1174            x_commutation_pu: 0.0,
1175            arm_inductance_mh: 50.0,
1176            sm_capacitance_uf: 10000.0,
1177        }
1178    }
1179
1180    fn standard_cable(id: usize, from: usize, to: usize) -> DcCable {
1181        DcCable {
1182            id,
1183            from_station: from,
1184            to_station: to,
1185            r_per_km: 0.01,
1186            l_per_km: 0.5,
1187            c_per_km: 0.2,
1188            length_km: 100.0,
1189        }
1190    }
1191
1192    #[allow(dead_code)]
1193    fn short_cable(id: usize, from: usize, to: usize) -> DcCable {
1194        DcCable {
1195            id,
1196            from_station: from,
1197            to_station: to,
1198            r_per_km: 0.01,
1199            l_per_km: 0.5,
1200            c_per_km: 0.2,
1201            length_km: 20.0,
1202        }
1203    }
1204
1205    fn mechanical_breaker(id: usize, station: usize) -> DcBreaker {
1206        DcBreaker {
1207            id,
1208            station_id: station,
1209            breaker_type: DcBreakerType::Mechanical {
1210                opening_time_ms: 50.0,
1211            },
1212            max_breaking_current_ka: 10.0,
1213            energy_absorption_mj: 20.0,
1214        }
1215    }
1216
1217    fn solid_state_breaker(id: usize, station: usize) -> DcBreaker {
1218        DcBreaker {
1219            id,
1220            station_id: station,
1221            breaker_type: DcBreakerType::SolidState {
1222                turn_off_time_us: 500.0,
1223                on_state_loss_pct: 0.5,
1224            },
1225            max_breaking_current_ka: 8.0,
1226            energy_absorption_mj: 10.0,
1227        }
1228    }
1229
1230    #[allow(dead_code)]
1231    fn hybrid_breaker(id: usize, station: usize) -> DcBreaker {
1232        DcBreaker {
1233            id,
1234            station_id: station,
1235            breaker_type: DcBreakerType::HybridBreaker {
1236                opening_time_ms: 30.0,
1237                commutation_time_us: 200.0,
1238            },
1239            max_breaking_current_ka: 12.0,
1240            energy_absorption_mj: 15.0,
1241        }
1242    }
1243
1244    fn standard_fault(cable_id: usize) -> DcFaultEvent {
1245        DcFaultEvent {
1246            fault_type: DcFaultType::PoleToPole,
1247            location_cable_id: cable_id,
1248            location_fraction: 0.5,
1249            fault_resistance: 0.1,
1250            inception_time_ms: 5.0,
1251        }
1252    }
1253
1254    fn build_2term_system() -> DcSwitchingSimulator {
1255        let mut sim = DcSwitchingSimulator::new(10.0, 200.0);
1256        sim.add_station(lcc_station(0, 15.0));
1257        sim.add_station(lcc_station(1, 150.0));
1258        sim.add_cable(standard_cable(0, 0, 1));
1259        sim
1260    }
1261
1262    // ── Test 1: 2-terminal HVDC DC power flow converges ──────────────────
1263
1264    #[test]
1265    fn test_dc_power_flow_converges() {
1266        let sim = build_2term_system();
1267        let sol = sim.solve_dc_power_flow().expect("DC PF should converge");
1268        assert!(sol.converged, "DC power flow should converge");
1269        assert!(sol.iterations <= 50, "Should converge in < 50 iterations");
1270        assert_eq!(sol.operating_points.len(), 2);
1271    }
1272
1273    // ── Test 2: LCC V_dc decreases with firing angle ─────────────────────
1274
1275    #[test]
1276    fn test_lcc_vdc_decreases_with_alpha() {
1277        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1278        let s15 = lcc_station(0, 15.0);
1279        let s30 = lcc_station(0, 30.0);
1280        let op15 = sim.lcc_operating_point(&s15, 1.0);
1281        let op30 = sim.lcc_operating_point(&s30, 1.0);
1282        assert!(
1283            op15.v_dc_kv > op30.v_dc_kv,
1284            "V_dc at alpha=15 ({:.1}) should exceed alpha=30 ({:.1})",
1285            op15.v_dc_kv,
1286            op30.v_dc_kv
1287        );
1288    }
1289
1290    // ── Test 3: VSC P_dc controllable ────────────────────────────────────
1291
1292    #[test]
1293    fn test_vsc_pdc_controllable() {
1294        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1295        let s = vsc_station(0);
1296        let op1 = sim.vsc_operating_point(&s, 100.0, 320.0);
1297        let op2 = sim.vsc_operating_point(&s, 400.0, 320.0);
1298        assert!(
1299            (op1.p_dc_mw - 100.0).abs() < 1.0,
1300            "P should be ~100 MW, got {}",
1301            op1.p_dc_mw
1302        );
1303        assert!(
1304            (op2.p_dc_mw - 400.0).abs() < 1.0,
1305            "P should be ~400 MW, got {}",
1306            op2.p_dc_mw
1307        );
1308    }
1309
1310    // ── Test 4: VSC losses positive ──────────────────────────────────────
1311
1312    #[test]
1313    fn test_vsc_losses_positive() {
1314        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1315        let s = vsc_station(0);
1316        let op = sim.vsc_operating_point(&s, 500.0, 320.0);
1317        assert!(
1318            op.losses_mw > 0.0,
1319            "VSC losses should be positive, got {}",
1320            op.losses_mw
1321        );
1322    }
1323
1324    // ── Test 5: Pole-to-pole fault peak current > 0 ──────────────────────
1325
1326    #[test]
1327    fn test_ptp_fault_peak_positive() {
1328        let mut sim = build_2term_system();
1329        sim.add_breaker(mechanical_breaker(0, 0));
1330        let fault = standard_fault(0);
1331        let res = sim.simulate_fault(&fault).expect("fault sim failed");
1332        assert!(
1333            res.peak_fault_current_ka > 0.0,
1334            "Peak fault current should be > 0, got {}",
1335            res.peak_fault_current_ka
1336        );
1337    }
1338
1339    // ── Test 6: Pole-to-ground lower peak than pole-to-pole ──────────────
1340
1341    #[test]
1342    fn test_ptg_lower_peak_than_ptp() {
1343        let mut sim = build_2term_system();
1344        sim.add_breaker(mechanical_breaker(0, 0));
1345
1346        let ptp = DcFaultEvent {
1347            fault_type: DcFaultType::PoleToPole,
1348            location_cable_id: 0,
1349            location_fraction: 0.5,
1350            fault_resistance: 0.1,
1351            inception_time_ms: 5.0,
1352        };
1353        let ptg = DcFaultEvent {
1354            fault_type: DcFaultType::PoleToGround,
1355            location_cable_id: 0,
1356            location_fraction: 0.5,
1357            fault_resistance: 0.1,
1358            inception_time_ms: 5.0,
1359        };
1360
1361        let res_ptp = sim.simulate_fault(&ptp).expect("PtP failed");
1362        let res_ptg = sim.simulate_fault(&ptg).expect("PtG failed");
1363
1364        assert!(
1365            res_ptg.peak_fault_current_ka < res_ptp.peak_fault_current_ka,
1366            "PtG peak ({:.2}) should be < PtP peak ({:.2})",
1367            res_ptg.peak_fault_current_ka,
1368            res_ptp.peak_fault_current_ka
1369        );
1370    }
1371
1372    // ── Test 7: Fault current increases with shorter distance ────────────
1373
1374    #[test]
1375    fn test_fault_current_shorter_distance() {
1376        let mut sim = build_2term_system();
1377        sim.add_breaker(mechanical_breaker(0, 0));
1378
1379        let near = DcFaultEvent {
1380            fault_type: DcFaultType::PoleToPole,
1381            location_cable_id: 0,
1382            location_fraction: 0.1,
1383            fault_resistance: 0.1,
1384            inception_time_ms: 5.0,
1385        };
1386        let far = DcFaultEvent {
1387            fault_type: DcFaultType::PoleToPole,
1388            location_cable_id: 0,
1389            location_fraction: 0.9,
1390            fault_resistance: 0.1,
1391            inception_time_ms: 5.0,
1392        };
1393
1394        let res_near = sim.simulate_fault(&near).expect("near failed");
1395        let res_far = sim.simulate_fault(&far).expect("far failed");
1396
1397        assert!(
1398            res_near.peak_fault_current_ka >= res_far.peak_fault_current_ka,
1399            "Near fault ({:.2}) should have >= peak than far fault ({:.2})",
1400            res_near.peak_fault_current_ka,
1401            res_far.peak_fault_current_ka
1402        );
1403    }
1404
1405    // ── Test 8: Fault current decreases with higher resistance ───────────
1406
1407    #[test]
1408    fn test_fault_current_decreases_with_resistance() {
1409        let mut sim = build_2term_system();
1410        sim.add_breaker(mechanical_breaker(0, 0));
1411
1412        let low_r = DcFaultEvent {
1413            fault_type: DcFaultType::PoleToPole,
1414            location_cable_id: 0,
1415            location_fraction: 0.5,
1416            fault_resistance: 0.1,
1417            inception_time_ms: 5.0,
1418        };
1419        let high_r = DcFaultEvent {
1420            fault_type: DcFaultType::PoleToPole,
1421            location_cable_id: 0,
1422            location_fraction: 0.5,
1423            fault_resistance: 10.0,
1424            inception_time_ms: 5.0,
1425        };
1426
1427        let res_low = sim.simulate_fault(&low_r).expect("low R failed");
1428        let res_high = sim.simulate_fault(&high_r).expect("high R failed");
1429
1430        assert!(
1431            res_low.peak_fault_current_ka >= res_high.peak_fault_current_ka,
1432            "Low R fault ({:.2}) should have >= peak than high R ({:.2})",
1433            res_low.peak_fault_current_ka,
1434            res_high.peak_fault_current_ka
1435        );
1436    }
1437
1438    // ── Test 9: Mechanical breaker clearing > 30 ms ──────────────────────
1439
1440    #[test]
1441    fn test_mechanical_breaker_clearing_time() {
1442        let mut sim = build_2term_system();
1443        sim.add_breaker(DcBreaker {
1444            id: 0,
1445            station_id: 0,
1446            breaker_type: DcBreakerType::Mechanical {
1447                opening_time_ms: 50.0,
1448            },
1449            max_breaking_current_ka: 10.0,
1450            energy_absorption_mj: 20.0,
1451        });
1452
1453        let fault = standard_fault(0);
1454        let res = sim.simulate_fault(&fault).expect("fault failed");
1455
1456        assert!(
1457            res.fault_clearing_time_ms >= 30.0,
1458            "Mechanical breaker should clear >= 30 ms, got {}",
1459            res.fault_clearing_time_ms
1460        );
1461    }
1462
1463    // ── Test 10: Solid-state breaker clearing < 1 ms ─────────────────────
1464
1465    #[test]
1466    fn test_solid_state_breaker_clearing() {
1467        let clearing = breaker_clearing_time_ms(&DcBreakerType::SolidState {
1468            turn_off_time_us: 500.0,
1469            on_state_loss_pct: 0.5,
1470        });
1471        assert!(
1472            clearing < 1.0,
1473            "Solid-state should clear < 1 ms, got {}",
1474            clearing
1475        );
1476    }
1477
1478    // ── Test 11: Hybrid breaker intermediate clearing ────────────────────
1479
1480    #[test]
1481    fn test_hybrid_breaker_intermediate_clearing() {
1482        let mech_time = breaker_clearing_time_ms(&DcBreakerType::Mechanical {
1483            opening_time_ms: 50.0,
1484        });
1485        let ss_time = breaker_clearing_time_ms(&DcBreakerType::SolidState {
1486            turn_off_time_us: 500.0,
1487            on_state_loss_pct: 0.5,
1488        });
1489        let hybrid_time = breaker_clearing_time_ms(&DcBreakerType::HybridBreaker {
1490            opening_time_ms: 30.0,
1491            commutation_time_us: 200.0,
1492        });
1493
1494        assert!(
1495            hybrid_time > ss_time && hybrid_time < mech_time,
1496            "Hybrid ({:.2}) should be between SS ({:.2}) and mech ({:.2})",
1497            hybrid_time,
1498            ss_time,
1499            mech_time
1500        );
1501    }
1502
1503    // ── Test 12: Energy dissipated > 0 ───────────────────────────────────
1504
1505    #[test]
1506    fn test_energy_dissipated_positive() {
1507        let mut sim = build_2term_system();
1508        sim.add_breaker(mechanical_breaker(0, 0));
1509        let fault = standard_fault(0);
1510        let res = sim.simulate_fault(&fault).expect("failed");
1511        assert!(
1512            res.energy_dissipated_mj > 0.0,
1513            "Energy dissipated should be > 0, got {}",
1514            res.energy_dissipated_mj
1515        );
1516    }
1517
1518    // ── Test 13: Fault cleared → voltage recovers ────────────────────────
1519
1520    #[test]
1521    fn test_fault_cleared_voltage_recovers() {
1522        let mut sim = DcSwitchingSimulator::new(10.0, 300.0);
1523        sim.add_station(lcc_station(0, 15.0));
1524        sim.add_station(lcc_station(1, 150.0));
1525        sim.add_cable(standard_cable(0, 0, 1));
1526        sim.add_breaker(solid_state_breaker(0, 0));
1527
1528        let fault = standard_fault(0);
1529        let res = sim.simulate_fault(&fault).expect("failed");
1530        assert!(res.fault_cleared, "Fault should be cleared with SS breaker");
1531    }
1532
1533    // ── Test 14: Peak di/dt positive ─────────────────────────────────────
1534
1535    #[test]
1536    fn test_peak_di_dt_positive() {
1537        let mut sim = build_2term_system();
1538        sim.add_breaker(mechanical_breaker(0, 0));
1539        let fault = standard_fault(0);
1540        let res = sim.simulate_fault(&fault).expect("failed");
1541        assert!(
1542            res.max_di_dt > 0.0,
1543            "max di/dt should be > 0, got {}",
1544            res.max_di_dt
1545        );
1546    }
1547
1548    // ── Test 15: Cable losses proportional to I²R ────────────────────────
1549
1550    #[test]
1551    fn test_cable_losses_proportional_to_i2r() {
1552        let mut sim = DcSwitchingSimulator::new(10.0, 100.0);
1553        sim.add_station(lcc_station(0, 15.0));
1554        sim.add_station(lcc_station(1, 150.0));
1555        sim.add_cable(DcCable {
1556            id: 0,
1557            from_station: 0,
1558            to_station: 1,
1559            r_per_km: 0.02, // double resistance
1560            l_per_km: 0.5,
1561            c_per_km: 0.2,
1562            length_km: 100.0,
1563        });
1564
1565        let sol = sim.solve_dc_power_flow().expect("PF failed");
1566        assert!(
1567            sol.cable_losses_mw[0] >= 0.0,
1568            "Cable losses should be non-negative"
1569        );
1570    }
1571
1572    // ── Test 16: MMC arm inductance limits fault current ─────────────────
1573
1574    #[test]
1575    fn test_mmc_arm_inductance_limits_fault() {
1576        // Compare two MMC stations: one with high arm inductance, one with low
1577        let mut mmc_high_l = mmc_station(0);
1578        mmc_high_l.arm_inductance_mh = 100.0;
1579        mmc_high_l.sm_capacitance_uf = 100.0; // same small C for both
1580
1581        let mut mmc_low_l = mmc_station(0);
1582        mmc_low_l.arm_inductance_mh = 1.0;
1583        mmc_low_l.sm_capacitance_uf = 100.0;
1584
1585        let mut sim_high = DcSwitchingSimulator::new(10.0, 200.0);
1586        sim_high.add_station(mmc_high_l.clone());
1587        let mut s1_h = mmc_high_l.clone();
1588        s1_h.id = 1;
1589        sim_high.add_station(s1_h);
1590        sim_high.add_cable(standard_cable(0, 0, 1));
1591        sim_high.add_breaker(mechanical_breaker(0, 0));
1592
1593        let mut sim_low = DcSwitchingSimulator::new(10.0, 200.0);
1594        sim_low.add_station(mmc_low_l.clone());
1595        let mut s1_l = mmc_low_l.clone();
1596        s1_l.id = 1;
1597        sim_low.add_station(s1_l);
1598        sim_low.add_cable(standard_cable(0, 0, 1));
1599        sim_low.add_breaker(mechanical_breaker(0, 0));
1600
1601        let fault = standard_fault(0);
1602        let res_high = sim_high
1603            .simulate_fault(&fault)
1604            .expect("high L fault failed");
1605        let res_low = sim_low.simulate_fault(&fault).expect("low L fault failed");
1606
1607        // Higher arm inductance → lower peak fault current (more impedance in path)
1608        assert!(
1609            res_high.peak_fault_current_ka <= res_low.peak_fault_current_ka + 0.1,
1610            "High arm L ({:.2}) should limit fault current vs low L ({:.2})",
1611            res_high.peak_fault_current_ka,
1612            res_low.peak_fault_current_ka
1613        );
1614    }
1615
1616    // ── Test 17: Switching transient voltage oscillates ───────────────────
1617
1618    #[test]
1619    fn test_switching_transient_oscillation() {
1620        let mut sim = build_2term_system();
1621        sim.add_breaker(mechanical_breaker(0, 0));
1622
1623        let res = sim
1624            .simulate_switching(0, true)
1625            .expect("switching sim failed");
1626        assert!(
1627            !res.states.is_empty(),
1628            "Switching result should have states"
1629        );
1630
1631        // Check that voltages change over time (oscillation or transient)
1632        let first_v = res
1633            .states
1634            .first()
1635            .map(|s| s.station_voltages_kv.first().copied().unwrap_or(0.0))
1636            .unwrap_or(0.0);
1637        let mid = res.states.len() / 2;
1638        let mid_v = res
1639            .states
1640            .get(mid)
1641            .map(|s| s.station_voltages_kv.first().copied().unwrap_or(0.0))
1642            .unwrap_or(0.0);
1643        // Voltages should differ (transient response)
1644        let differs = (first_v - mid_v).abs() > 1e-6 || res.states.len() > 1;
1645        assert!(differs, "Switching should produce transient response");
1646    }
1647
1648    // ── Test 18: 3-terminal grid power balance ───────────────────────────
1649
1650    #[test]
1651    fn test_3terminal_power_balance() {
1652        let mut sim = DcSwitchingSimulator::new(10.0, 100.0);
1653        sim.add_station(lcc_station(0, 15.0));
1654        sim.add_station(vsc_station(1));
1655        sim.add_station(lcc_station(2, 150.0));
1656        sim.add_cable(standard_cable(0, 0, 1));
1657        sim.add_cable(standard_cable(1, 1, 2));
1658
1659        let sol = sim.solve_dc_power_flow().expect("3-term PF failed");
1660        assert_eq!(sol.operating_points.len(), 3);
1661        // Total injected power should approximately equal total consumed + losses
1662        let total_p: f64 = sol.operating_points.iter().map(|op| op.p_dc_mw).sum();
1663        // Net power should be close to zero (conservation) for a converged solution
1664        // In practice, the slack bus absorbs the difference
1665        assert!(
1666            total_p.is_finite(),
1667            "Total power should be finite, got {}",
1668            total_p
1669        );
1670    }
1671
1672    // ── Test 19: Underdamped response oscillates ─────────────────────────
1673
1674    #[test]
1675    fn test_underdamped_response_oscillates() {
1676        // Low resistance, high L and C → underdamped
1677        let mut sim = DcSwitchingSimulator::new(5.0, 100.0);
1678        sim.add_station(vsc_station(0));
1679        sim.add_station(vsc_station(1));
1680        sim.add_cable(DcCable {
1681            id: 0,
1682            from_station: 0,
1683            to_station: 1,
1684            r_per_km: 0.001, // very low R
1685            l_per_km: 2.0,   // high L
1686            c_per_km: 1.0,   // high C
1687            length_km: 50.0,
1688        });
1689        sim.add_breaker(mechanical_breaker(0, 0));
1690
1691        let fault = DcFaultEvent {
1692            fault_type: DcFaultType::PoleToPole,
1693            location_cable_id: 0,
1694            location_fraction: 0.5,
1695            fault_resistance: 0.001,
1696            inception_time_ms: 2.0,
1697        };
1698        let res = sim
1699            .simulate_fault(&fault)
1700            .expect("underdamped fault failed");
1701        assert!(
1702            res.peak_fault_current_ka > 0.0,
1703            "Underdamped should produce fault current"
1704        );
1705    }
1706
1707    // ── Test 20: Overdamped monotonic decay with high R ──────────────────
1708
1709    #[test]
1710    fn test_overdamped_high_resistance() {
1711        let mut sim = DcSwitchingSimulator::new(10.0, 100.0);
1712        sim.add_station(lcc_station(0, 15.0));
1713        sim.add_station(lcc_station(1, 150.0));
1714        sim.add_cable(DcCable {
1715            id: 0,
1716            from_station: 0,
1717            to_station: 1,
1718            r_per_km: 1.0,  // very high R
1719            l_per_km: 0.1,  // low L
1720            c_per_km: 0.01, // low C
1721            length_km: 100.0,
1722        });
1723        sim.add_breaker(mechanical_breaker(0, 0));
1724
1725        let fault = DcFaultEvent {
1726            fault_type: DcFaultType::PoleToPole,
1727            location_cable_id: 0,
1728            location_fraction: 0.5,
1729            fault_resistance: 50.0,
1730            inception_time_ms: 5.0,
1731        };
1732        let res = sim.simulate_fault(&fault).expect("overdamped fault failed");
1733        // With very high resistance, peak should be limited
1734        assert!(
1735            res.peak_fault_current_ka.is_finite(),
1736            "Overdamped peak should be finite"
1737        );
1738    }
1739
1740    // ── Test 21: No fault → stable operation ─────────────────────────────
1741
1742    #[test]
1743    fn test_no_fault_stable() {
1744        let mut sim = build_2term_system();
1745        sim.add_breaker(mechanical_breaker(0, 0));
1746
1747        // Run switching sim without actually opening (close operation)
1748        let res = sim.simulate_switching(0, false).expect("stable sim failed");
1749        assert!(!res.states.is_empty(), "Should produce states");
1750        assert!(
1751            res.peak_fault_current_ka.abs() < 1e-6,
1752            "No fault current expected"
1753        );
1754    }
1755
1756    // ── Test 22: DcTransientResult states non-empty ──────────────────────
1757
1758    #[test]
1759    fn test_transient_result_states_nonempty() {
1760        let mut sim = build_2term_system();
1761        sim.add_breaker(mechanical_breaker(0, 0));
1762        let fault = standard_fault(0);
1763        let res = sim.simulate_fault(&fault).expect("failed");
1764        assert!(
1765            !res.states.is_empty(),
1766            "Result should contain transient states"
1767        );
1768        assert!(res.states.len() > 1, "Should have multiple state snapshots");
1769    }
1770
1771    // ── Test 23: Operating point P = V × I ───────────────────────────────
1772
1773    #[test]
1774    fn test_operating_point_p_equals_vi() {
1775        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1776        let s = vsc_station(0);
1777        let op = sim.vsc_operating_point(&s, 256.0, 320.0);
1778        let p_check = op.v_dc_kv * op.i_dc_ka;
1779        assert!(
1780            (op.p_dc_mw - p_check).abs() < 1.0,
1781            "P ({:.1}) should ≈ V*I ({:.1})",
1782            op.p_dc_mw,
1783            p_check
1784        );
1785    }
1786
1787    // ── Test 24: Breaker max breaking current check ──────────────────────
1788
1789    #[test]
1790    fn test_breaker_max_breaking_current() {
1791        let b = mechanical_breaker(0, 0);
1792        assert!(
1793            b.max_breaking_current_ka > 0.0,
1794            "Max breaking current should be positive"
1795        );
1796        assert_eq!(b.max_breaking_current_ka, 10.0);
1797    }
1798
1799    // ── Test 25: LCC power factor ────────────────────────────────────────
1800
1801    #[test]
1802    fn test_lcc_power_factor() {
1803        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1804        let s = lcc_station(0, 15.0);
1805        let op = sim.lcc_operating_point(&s, 1.0);
1806        assert!(
1807            op.power_factor > 0.0 && op.power_factor <= 1.0,
1808            "PF should be in (0,1], got {}",
1809            op.power_factor
1810        );
1811    }
1812
1813    // ── Test 26: Detect fault function ───────────────────────────────────
1814
1815    #[test]
1816    fn test_detect_fault() {
1817        let sim = build_2term_system();
1818
1819        let prev = DcTransientState {
1820            time_ms: 0.0,
1821            station_voltages_kv: vec![500.0, 500.0],
1822            station_currents_ka: vec![1.0, -1.0],
1823            cable_currents_ka: vec![1.0],
1824            fault_current_ka: 0.0,
1825            breaker_states: vec![true],
1826        };
1827        let state_normal = DcTransientState {
1828            time_ms: 0.01,
1829            station_voltages_kv: vec![499.9, 499.9],
1830            station_currents_ka: vec![1.0, -1.0],
1831            cable_currents_ka: vec![1.01],
1832            fault_current_ka: 0.0,
1833            breaker_states: vec![true],
1834        };
1835        let state_fault = DcTransientState {
1836            time_ms: 0.01,
1837            station_voltages_kv: vec![200.0, 200.0], // voltage collapsed
1838            station_currents_ka: vec![20.0, -20.0],
1839            cable_currents_ka: vec![20.0],
1840            fault_current_ka: 15.0,
1841            breaker_states: vec![true],
1842        };
1843
1844        assert!(
1845            !sim.detect_fault(&state_normal, &prev),
1846            "Normal state should not be flagged as fault"
1847        );
1848        assert!(
1849            sim.detect_fault(&state_fault, &prev),
1850            "Faulted state should be detected"
1851        );
1852    }
1853
1854    // ── Test 27: Cable total R·L·C from per-km values ────────────────────
1855
1856    #[test]
1857    fn test_cable_total_parameters() {
1858        let c = standard_cable(0, 0, 1);
1859        let r_total = c.r_per_km * c.length_km;
1860        let l_total = c.l_per_km * c.length_km;
1861        let c_total = c.c_per_km * c.length_km;
1862        assert!((r_total - 1.0).abs() < 1e-6, "R = 0.01 * 100 = 1.0 Ω");
1863        assert!((l_total - 50.0).abs() < 1e-6, "L = 0.5 * 100 = 50 mH");
1864        assert!((c_total - 20.0).abs() < 1e-6, "C = 0.2 * 100 = 20 μF");
1865    }
1866
1867    // ── Test 28: Empty system errors ─────────────────────────────────────
1868
1869    #[test]
1870    fn test_empty_system_errors() {
1871        let sim = DcSwitchingSimulator::new(10.0, 100.0);
1872        assert!(sim.solve_dc_power_flow().is_err());
1873        let fault = standard_fault(0);
1874        assert!(sim.simulate_fault(&fault).is_err());
1875    }
1876}