Skip to main content

surge_network/network/
branch.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Branch (transmission line / transformer) representation.
3
4use num_complex::Complex64;
5use serde::{Deserialize, Serialize};
6
7use crate::dynamics::{CoreLossModel, CoreType, TransformerSaturation};
8use crate::market::AmbientConditions;
9
10/// Zero-sequence winding connection type for transformers.
11///
12/// Determines how zero-sequence current propagates (or is blocked) through a
13/// transformer. Standard ANSI/IEEE notation: G = grounded neutral.
14///
15/// # Zero-sequence rules (IEC 60909 / IEEE Std 1110)
16///
17/// | Connection  | Zero-sequence path |
18/// |-------------|--------------------|
19/// | WyeGWyeG    | Passes freely through transformer (both sides grounded) |
20/// | WyeGDelta   | Blocked on delta (secondary) side; grounded-wye primary sees zero-seq |
21/// | DeltaWyeG   | Blocked on delta (primary) side; grounded-wye secondary sees zero-seq |
22/// | DeltaDelta  | Blocked completely — no zero-sequence path through the transformer |
23/// | WyeGWye     | Blocked — ungrounded wye presents no zero-seq return path |
24#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
25pub enum TransformerConnection {
26    /// Both sides grounded wye — passes zero-sequence freely (default).
27    ///
28    /// Use for transformers where both neutrals are solidly grounded.
29    /// Zero-sequence admittance is modeled identically to positive-sequence.
30    #[default]
31    WyeGWyeG,
32    /// Primary grounded wye, secondary delta — blocks zero-sequence on secondary.
33    ///
34    /// The grounded-wye primary can carry zero-sequence current, but the delta
35    /// secondary circulates it internally (no path to the secondary bus). In
36    /// the zero-sequence Y-bus, only the primary-side shunt admittance appears
37    /// as a self-admittance at the primary bus; the off-diagonal entries are zero.
38    WyeGDelta,
39    /// Primary delta, secondary grounded wye — blocks zero-sequence on primary.
40    ///
41    /// Symmetric to `WyeGDelta`: the grounded-wye secondary bus sees a shunt
42    /// to ground but no coupling to the primary bus in the zero-sequence network.
43    DeltaWyeG,
44    /// Both sides delta — blocks zero-sequence completely.
45    ///
46    /// No zero-sequence current can pass through or terminate at either winding.
47    /// The transformer is omitted entirely from the zero-sequence Y-bus.
48    DeltaDelta,
49    /// Grounded wye, ungrounded wye — blocks zero-sequence.
50    ///
51    /// The ungrounded wye presents no return path for zero-sequence current.
52    /// Treated the same as `DeltaDelta` in the zero-sequence network.
53    WyeGWye,
54}
55
56/// Tap-ratio control mode for AC-OPF.
57///
58/// When `Continuous`, the AC-OPF treats the off-nominal turns ratio as a
59/// continuous NLP variable bounded by `[BranchOpfControl::tap_min, BranchOpfControl::tap_max]`.
60#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TapMode {
63    /// Tap ratio is fixed at `Branch::tap` (default).
64    #[default]
65    Fixed,
66    /// Tap ratio is a continuous NLP variable in AC-OPF.
67    Continuous,
68}
69
70/// Phase-shifting transformer control mode for AC-OPF.
71///
72/// When `Continuous`, the AC-OPF treats the phase shift angle as a
73/// continuous NLP variable bounded by
74/// `[BranchOpfControl::phase_min_rad, BranchOpfControl::phase_max_rad]`.
75#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum PhaseMode {
78    /// Phase shift is fixed at `Branch::phase_shift_rad` (default).
79    #[default]
80    Fixed,
81    /// Phase shift is a continuous NLP variable in AC-OPF.
82    Continuous,
83}
84
85/// Branch type classification.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
87pub enum BranchType {
88    /// Transmission line (overhead or cable).
89    #[default]
90    Line,
91    /// Two-winding transformer.
92    Transformer,
93    /// Three-winding transformer (star-bus expanded into 3 two-winding branches).
94    Transformer3W,
95    /// Series capacitor or series reactor (negative reactance).
96    SeriesCapacitor,
97    /// Zero-impedance tie line (bus coupler or closed switch).
98    ZeroImpedanceTie,
99}
100
101/// Physical line construction type.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum LineType {
104    /// Overhead transmission line on towers/poles.
105    Overhead,
106    /// Underground cable (typically higher capacitance, lower inductance).
107    UndergroundCable,
108    /// Submarine cable (subsea crossing).
109    SubmarineCable,
110}
111
112/// Transformer winding connection type.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114pub enum WindingConnection {
115    /// Wye (star) connection, ungrounded neutral.
116    Wye,
117    /// Wye (star) connection, solidly grounded neutral.
118    WyeGrounded,
119    /// Delta (mesh) connection.
120    Delta,
121    /// Zigzag (interconnected star) connection.
122    Zigzag,
123    /// Autotransformer connection (shared winding).
124    Auto,
125}
126
127// ---------------------------------------------------------------------------
128// Sub-structs — optional groups of related fields factored out of Branch.
129// ---------------------------------------------------------------------------
130
131/// OPF tap/phase optimization parameters for a branch.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct BranchOpfControl {
134    /// Tap-ratio control mode for AC-OPF (default: `Fixed`).
135    #[serde(default)]
136    pub tap_mode: TapMode,
137    /// Minimum tap ratio (pu) when `tap_mode = Continuous`. Typical: 0.9.
138    #[serde(default = "BranchOpfControl::default_tap_min")]
139    pub tap_min: f64,
140    /// Maximum tap ratio (pu) when `tap_mode = Continuous`. Typical: 1.1.
141    #[serde(default = "BranchOpfControl::default_tap_max")]
142    pub tap_max: f64,
143    /// Discrete tap step size (pu). Used for post-solve rounding in discrete AC-OPF.
144    ///
145    /// When `> 0`, the continuous NLP tap solution is rounded to the nearest
146    /// `tap_min + n * tap_step` value. `0.0` = continuous (no rounding).
147    /// Typical OLTC: `0.00625` (1/160, 16 steps over +/-10%).
148    #[serde(default)]
149    pub tap_step: f64,
150    /// Phase-shifter control mode for AC-OPF (default: `Fixed`).
151    #[serde(default)]
152    pub phase_mode: PhaseMode,
153    /// Minimum phase shift (radians) when `phase_mode = Continuous`. Typical: -30 deg.
154    #[serde(
155        default = "BranchOpfControl::default_phase_min_rad",
156        alias = "phase_min_deg"
157    )]
158    pub phase_min_rad: f64,
159    /// Maximum phase shift (radians) when `phase_mode = Continuous`. Typical: 30 deg.
160    #[serde(
161        default = "BranchOpfControl::default_phase_max_rad",
162        alias = "phase_max_deg"
163    )]
164    pub phase_max_rad: f64,
165    /// Discrete phase-shift step size (radians). Used for post-solve rounding.
166    ///
167    /// When `> 0`, the continuous NLP phase solution is rounded to the nearest
168    /// discrete step. `0.0` = continuous (no rounding).
169    #[serde(default, alias = "phase_step_deg")]
170    pub phase_step_rad: f64,
171}
172
173impl BranchOpfControl {
174    fn default_tap_min() -> f64 {
175        0.9
176    }
177    fn default_tap_max() -> f64 {
178        1.1
179    }
180    fn default_phase_min_rad() -> f64 {
181        (-30.0_f64).to_radians()
182    }
183    fn default_phase_max_rad() -> f64 {
184        30.0_f64.to_radians()
185    }
186}
187
188impl Default for BranchOpfControl {
189    fn default() -> Self {
190        Self {
191            tap_mode: TapMode::Fixed,
192            tap_min: 0.9,
193            tap_max: 1.1,
194            tap_step: 0.0,
195            phase_mode: PhaseMode::Fixed,
196            phase_min_rad: (-30.0_f64).to_radians(),
197            phase_max_rad: 30.0_f64.to_radians(),
198            phase_step_rad: 0.0,
199        }
200    }
201}
202
203/// Physical line properties.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct LineData {
206    /// Line length in km.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub length_km: Option<f64>,
209    /// Physical line construction type.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub line_type: Option<LineType>,
212    /// Conductor designation (e.g. "Drake", "Falcon").
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub conductor: Option<String>,
215    /// Number of sub-conductors per bundle.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub n_bundles: Option<u32>,
218    /// Resistance-temperature coefficient (1/deg-C). Default 0.
219    #[serde(default)]
220    pub r_temp_coeff: f64,
221    /// Reference temperature for rated R (deg-C). Default 20.
222    #[serde(default = "LineData::default_r_ref_temp_c")]
223    pub r_ref_temp_c: f64,
224}
225
226impl LineData {
227    fn default_r_ref_temp_c() -> f64 {
228        20.0
229    }
230}
231
232impl Default for LineData {
233    fn default() -> Self {
234        Self {
235            length_km: None,
236            line_type: None,
237            conductor: None,
238            n_bundles: None,
239            r_temp_coeff: 0.0,
240            r_ref_temp_c: 20.0,
241        }
242    }
243}
244
245/// Transformer winding identity and nameplate data.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct TransformerData {
248    /// Transformer zero-sequence winding connection (FPQ-01).
249    ///
250    /// Controls how zero-sequence current propagates through this transformer
251    /// in the fault analysis zero-sequence Y-bus:
252    ///
253    /// - `WyeGWyeG` (default): passes zero-sequence freely — same admittance as positive-seq.
254    /// - `WyeGDelta`: primary (from) side sees zero-seq shunt to ground; secondary blocked.
255    /// - `DeltaWyeG`: secondary (to) side sees zero-seq shunt to ground; primary blocked.
256    /// - `DeltaDelta`: transformer is skipped entirely in the zero-sequence Y-bus.
257    /// - `WyeGWye`: same as `DeltaDelta` — no zero-sequence path.
258    ///
259    /// For transmission lines (non-transformers), this field is ignored.
260    #[serde(default)]
261    pub transformer_connection: TransformerConnection,
262    /// Winding rated kV (individual winding, not line-to-line).
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub winding_rated_kv: Option<f64>,
265    /// Winding rated MVA.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub winding_rated_mva: Option<f64>,
268    /// Parent 3-winding transformer ID (star-bus expansion).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub parent_transformer_id: Option<String>,
271    /// Winding number within parent transformer (1, 2, or 3).
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub winding_number: Option<u8>,
274    /// Winding connection type.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub winding_connection: Option<WindingConnection>,
277    /// Neutral impedance of this winding (pu, system base).
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub zn_winding: Option<Complex64>,
280    /// Oil temperature limit in deg-C (from CGMES OilTemperatureLimit, informational).
281    ///
282    /// This is the transformer insulating oil temperature threshold at which the equipment
283    /// is rated (PATL or TATL depending on OperationalLimitType). Stored per the CIM spec
284    /// (OilTemperatureLimit attaches to ConductingEquipment via OperationalLimitSet).
285    /// Not converted to MVA — requires equipment-specific thermal derating curves.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub oil_temp_limit_c: Option<f64>,
288    /// Winding temperature limit in deg-C (from CGMES WindingTemperatureLimit, informational).
289    ///
290    /// Temperature threshold for the transformer winding insulation. Same structure as
291    /// OilTemperatureLimit — stored per CIM spec, not converted to MVA.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub winding_temp_limit_c: Option<f64>,
294    /// Impedance limit in Ohms (from CGMES ImpedanceLimit, informational).
295    ///
296    /// Represents a protection or operational limit on the series impedance magnitude.
297    /// Stored per CIM spec (ImpedanceLimit attaches to ConductingEquipment via
298    /// OperationalLimitSet). Not applied to the admittance model.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub impedance_limit_ohm: Option<f64>,
301}
302
303impl Default for TransformerData {
304    fn default() -> Self {
305        Self {
306            transformer_connection: TransformerConnection::WyeGWyeG,
307            winding_rated_kv: None,
308            winding_rated_mva: None,
309            parent_transformer_id: None,
310            winding_number: None,
311            winding_connection: None,
312            zn_winding: None,
313            oil_temp_limit_c: None,
314            winding_temp_limit_c: None,
315            impedance_limit_ohm: None,
316        }
317    }
318}
319
320/// Series capacitor/reactor protection data.
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct SeriesCompData {
323    /// Bypass current threshold (kA) for series capacitor protection.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub bypass_current_ka: Option<f64>,
326    /// Rated reactive power of series element (MVAr).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub rated_mvar_series: Option<f64>,
329    /// Series capacitor is currently bypassed.
330    #[serde(default)]
331    pub bypassed: bool,
332}
333
334/// Zero-sequence impedance data for fault analysis.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ZeroSeqData {
337    /// Zero-sequence series resistance in per-unit (system base).
338    ///
339    /// From PSS/E `.seq` RLINZ field or CGMES per-length-impedance data.
340    pub r0: f64,
341    /// Zero-sequence series reactance in per-unit (system base).
342    ///
343    /// From PSS/E `.seq` XLINZ field.
344    pub x0: f64,
345    /// Zero-sequence total charging susceptance in per-unit (system base).
346    ///
347    /// From PSS/E `.seq` BCHZ field.
348    pub b0: f64,
349    /// Transformer neutral grounding impedance Zn (per-unit on system base).
350    ///
351    /// For grounded-wye transformer windings, `3*Zn` is added in series with the
352    /// zero-sequence impedance. This increases zero-sequence impedance and reduces
353    /// SLG fault currents, while 3LG faults are unaffected.
354    ///
355    /// From PSS/E `.seq` RG1/XG1 (primary) or RG2/XG2 (secondary) fields.
356    /// `None` = solidly grounded (Zn = 0).
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub zn: Option<Complex64>,
359    /// Zero-sequence terminal shunt conductance at the from-bus end (pu, system base).
360    ///
361    /// From PSS/E `.seq` GI field (token 6). Non-zero for underground cables with
362    /// significant dielectric losses. Appears in the zero-sequence Y-bus as a shunt
363    /// admittance at the from-bus diagonal. Defaults to 0 (zero) when absent.
364    #[serde(default)]
365    pub gi0: f64,
366    /// Zero-sequence terminal shunt susceptance at the from-bus end (pu, system base).
367    ///
368    /// From PSS/E `.seq` BI field (token 7). See `gi0` for context.
369    #[serde(default)]
370    pub bi0: f64,
371    /// Zero-sequence terminal shunt conductance at the to-bus end (pu, system base).
372    ///
373    /// From PSS/E `.seq` GJ field (token 8).
374    #[serde(default)]
375    pub gj0: f64,
376    /// Zero-sequence terminal shunt susceptance at the to-bus end (pu, system base).
377    ///
378    /// From PSS/E `.seq` BJ field (token 9).
379    #[serde(default)]
380    pub bj0: f64,
381    /// Whether this transformer winding is delta-connected.
382    ///
383    /// A delta-wound transformer blocks zero-sequence currents and triplen harmonics
384    /// (3rd, 9th, 15th, ...). When `true` and the harmonic order `h` satisfies `h % 3 == 0`,
385    /// the harmonic Y-bus builder zeros out this branch's admittance contribution,
386    /// preventing triplen harmonic current from propagating through the delta winding.
387    ///
388    /// Default: `false` (wye-connected or transmission line — no blocking).
389    #[serde(default)]
390    pub delta_connected: bool,
391}
392
393impl Default for ZeroSeqData {
394    fn default() -> Self {
395        Self {
396            r0: 0.0,
397            x0: 0.0,
398            b0: 0.0,
399            zn: None,
400            gi0: 0.0,
401            bi0: 0.0,
402            gj0: 0.0,
403            bj0: 0.0,
404            delta_connected: false,
405        }
406    }
407}
408
409/// Harmonic analysis data for a branch.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct HarmonicData {
412    /// Skin-effect resistance correction coefficient for harmonic analysis (FPQ-22).
413    ///
414    /// At harmonic order h, the effective AC resistance is scaled by:
415    ///   `R_h = R_1 * (1.0 + skin_effect_alpha * (h - 1))`
416    ///
417    /// This IEC 60287-simplified model accounts for current crowding toward the
418    /// conductor surface at higher frequencies. Typical values:
419    /// - `0.0` — no skin effect (default; appropriate for conductors < 100 mm^2)
420    /// - `0.01-0.05` — medium conductors (100-300 mm^2)
421    /// - `0.05-0.10` — large conductors (> 300 mm^2, ACSR bundled)
422    ///
423    /// The reactance X_h is unaffected (it scales linearly with h as omega*L).
424    #[serde(default)]
425    pub skin_effect_alpha: f64,
426    /// Transformer core saturation characteristic for nonlinear harmonic analysis.
427    ///
428    /// When `Some`, the iterative harmonic solver computes voltage-dependent
429    /// magnetizing harmonic currents from this curve. When `None`, the
430    /// magnetizing branch uses the linear shunt admittance (g_mag + j*b_mag).
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub saturation: Option<TransformerSaturation>,
433    /// Transformer core construction type (affects GIC K-factor and saturation).
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub core_type: Option<CoreType>,
436    /// Frequency-dependent core loss decomposition for harmonic analysis.
437    ///
438    /// When `Some`, the harmonic Y-bus uses frequency-scaled g_core(h) instead
439    /// of constant g_mag. When `None`, uses `CoreLossModel::default()`.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub core_loss_model: Option<CoreLossModel>,
442}
443
444impl Default for HarmonicData {
445    fn default() -> Self {
446        Self {
447            skin_effect_alpha: 0.0,
448            saturation: None,
449            core_type: None,
450            core_loss_model: None,
451        }
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Branch — core struct with optional sub-struct groups.
457// ---------------------------------------------------------------------------
458
459/// A branch connecting two buses in the power system network.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct Branch {
462    /// From bus number.
463    pub from_bus: u32,
464    /// To bus number.
465    pub to_bus: u32,
466    /// Circuit identifier (for parallel lines).
467    pub circuit: String,
468    /// Series resistance in per-unit.
469    pub r: f64,
470    /// Series reactance in per-unit.
471    pub x: f64,
472    /// Total line charging susceptance in per-unit.
473    pub b: f64,
474    /// Line charging conductance (total, pu on system base).
475    ///
476    /// Half is applied at each end of the pi-circuit model, matching the `b/2`
477    /// convention for line charging susceptance.  Zero for most overhead
478    /// transmission lines; non-zero for underground cables with significant
479    /// dielectric losses, as found in some CGMES/CIM datasets (ACLineSegment
480    /// `gch` field).
481    #[serde(default)]
482    pub g_pi: f64,
483    /// Transformer off-nominal turns ratio (1.0 for lines).
484    pub tap: f64,
485    /// Transformer phase shift angle in **radians** (0.0 for lines).
486    ///
487    /// IO parsers convert from degrees at the boundary.
488    #[serde(alias = "phase_shift_deg")]
489    pub phase_shift_rad: f64,
490    /// Transformer magnetizing conductance (pu on system base).
491    ///
492    /// Represents the real (loss) component of the transformer core admittance.
493    /// Modeled as a shunt at the winding-1 (from-bus) terminal in the Y-bus.
494    /// PSS/E MAG1 field. Zero for transmission lines and transformers without
495    /// explicit magnetizing data.
496    #[serde(default)]
497    pub g_mag: f64,
498    /// Transformer magnetizing susceptance (pu on system base).
499    ///
500    /// Represents the reactive (magnetizing) component of the transformer core
501    /// admittance. Modeled as a shunt at the winding-1 (from-bus) terminal in
502    /// the Y-bus. PSS/E MAG2 field. Zero for transmission lines and
503    /// transformers without explicit magnetizing data.
504    #[serde(default)]
505    pub b_mag: f64,
506    /// From-terminal shunt conductance in per-unit.
507    ///
508    /// Optional asymmetric per-side shunt-to-ground component, added on top
509    /// of the symmetric `g_pi/2` split. Corresponds to GO Competition
510    /// Challenge 3 §4.8 `g^fr_j` (eqs 148, 150) when the branch has
511    /// `additional_shunt = 1`. Zero for lines without per-side data.
512    #[serde(default)]
513    pub g_shunt_from: f64,
514    /// From-terminal shunt susceptance in per-unit.
515    ///
516    /// Optional asymmetric per-side shunt-to-ground component, added on top
517    /// of the symmetric `b/2` charging split. Corresponds to GO Competition
518    /// Challenge 3 §4.8 `b^fr_j` (eq 149) when the branch has
519    /// `additional_shunt = 1`. Zero for lines without per-side data.
520    #[serde(default)]
521    pub b_shunt_from: f64,
522    /// To-terminal shunt conductance in per-unit.
523    ///
524    /// Optional asymmetric per-side shunt-to-ground component, added on top
525    /// of the symmetric `g_pi/2` split. Corresponds to GO Competition
526    /// Challenge 3 §4.8 `g^to_j` (eqs 148, 150) when the branch has
527    /// `additional_shunt = 1`. Zero for lines without per-side data.
528    #[serde(default)]
529    pub g_shunt_to: f64,
530    /// To-terminal shunt susceptance in per-unit.
531    ///
532    /// Optional asymmetric per-side shunt-to-ground component, added on top
533    /// of the symmetric `b/2` charging split. Corresponds to GO Competition
534    /// Challenge 3 §4.8 `b^to_j` (eq 151) when the branch has
535    /// `additional_shunt = 1`. Zero for lines without per-side data.
536    #[serde(default)]
537    pub b_shunt_to: f64,
538    /// Long-term rating (MVA).
539    pub rating_a_mva: f64,
540    /// Short-term rating (MVA).
541    pub rating_b_mva: f64,
542    /// Emergency rating (MVA).
543    pub rating_c_mva: f64,
544    /// Branch status (true = in service).
545    pub in_service: bool,
546    /// Fixed cost ($) charged when the branch transitions from open to
547    /// closed (i.e. `u^on_jt = 1` after `u^on_j,t-1 = 0`).
548    ///
549    /// GO Competition Challenge 3 §4.4.6 eq (62) prices the startup variable
550    /// `u^su_jt` at `c^su_j` for all `j ∈ J^pr,cs,ac`, which includes AC
551    /// branch devices. Populated by the GO C3 adapter from the JSON
552    /// `connection_cost` field on `ac_line` and `two_winding_transformer`
553    /// records. Zero for non-GO datasets or branches without a declared
554    /// cost. Only consulted by SCUC when `allow_branch_switching = true`;
555    /// the default `AllowSwitching = 0` path pins the branch on/off columns.
556    #[serde(default)]
557    pub cost_startup: f64,
558    /// Fixed cost ($) charged when the branch transitions from closed to
559    /// open (i.e. `u^on_jt = 0` after `u^on_j,t-1 = 1`).
560    ///
561    /// GO Competition Challenge 3 §4.4.6 eq (63) prices the shutdown
562    /// variable `u^sd_jt` at `c^sd_j` for all `j ∈ J^pr,cs,ac`. Populated
563    /// by the GO C3 adapter from the JSON `disconnection_cost` field on
564    /// `ac_line` and `two_winding_transformer` records. Zero for non-GO
565    /// datasets or branches without a declared cost. Only consulted by
566    /// SCUC when `allow_branch_switching = true`.
567    #[serde(default)]
568    pub cost_shutdown: f64,
569    /// Minimum phase angle difference across branch (from - to) in **radians**.
570    ///
571    /// Convention: all internal angle quantities are in radians.  IO parsers
572    /// (MATPOWER, PSS/E, etc.) convert from degrees at the boundary.
573    /// `None` = unconstrained (equivalent to -2pi).
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub angle_diff_min_rad: Option<f64>,
576    /// Maximum phase angle difference across branch (from - to) in **radians**.
577    ///
578    /// Convention: all internal angle quantities are in radians.  IO parsers
579    /// (MATPOWER, PSS/E, etc.) convert from degrees at the boundary.
580    /// `None` = unconstrained (equivalent to +2pi).
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub angle_diff_max_rad: Option<f64>,
583    /// Branch type classification.
584    #[serde(default)]
585    pub branch_type: BranchType,
586    /// Impedance correction table number (PSS/E TAB1 field).
587    ///
588    /// When set, the branch's R and X are scaled by the interpolated factor
589    /// from `Network::impedance_corrections` at the current tap position
590    /// before Y-bus construction. `None` means no correction (default).
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub tab: Option<u32>,
593    /// Per-branch ambient conditions for dynamic line rating.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub ambient: Option<AmbientConditions>,
596    /// Ownership entries (PSS/E O1,F1..O4,F4). Up to 4 co-owners.
597    #[serde(default, skip_serializing_if = "Vec::is_empty")]
598    pub owners: Vec<super::owner::OwnershipEntry>,
599
600    // --- optional sub-structs ---
601    /// OPF tap/phase optimization parameters.
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub opf_control: Option<BranchOpfControl>,
604    /// Physical line properties.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub line_data: Option<LineData>,
607    /// Transformer winding identity and nameplate data.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub transformer_data: Option<TransformerData>,
610    /// Series capacitor/reactor protection data.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub series_comp: Option<SeriesCompData>,
613    /// Zero-sequence impedance data for fault analysis.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub zero_seq: Option<ZeroSeqData>,
616    /// Harmonic analysis data.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub harmonic: Option<HarmonicData>,
619}
620
621impl Default for Branch {
622    fn default() -> Self {
623        Self {
624            from_bus: 0,
625            to_bus: 0,
626            circuit: "1".to_string(),
627            r: 0.0,
628            x: 0.0,
629            b: 0.0,
630            g_pi: 0.0,
631            tap: 1.0,
632            phase_shift_rad: 0.0,
633            g_mag: 0.0,
634            b_mag: 0.0,
635            g_shunt_from: 0.0,
636            b_shunt_from: 0.0,
637            g_shunt_to: 0.0,
638            b_shunt_to: 0.0,
639            rating_a_mva: 0.0,
640            rating_b_mva: 0.0,
641            rating_c_mva: 0.0,
642            in_service: true,
643            cost_startup: 0.0,
644            cost_shutdown: 0.0,
645            angle_diff_min_rad: None,
646            angle_diff_max_rad: None,
647            branch_type: BranchType::Line,
648            tab: None,
649            ambient: None,
650            owners: Vec::new(),
651            opf_control: None,
652            line_data: None,
653            transformer_data: None,
654            series_comp: None,
655            zero_seq: None,
656            harmonic: None,
657        }
658    }
659}
660
661/// Canonical pi-model admittance parameters for a branch.
662#[derive(Debug, Clone, Copy, PartialEq)]
663pub struct BranchPiAdmittance {
664    pub g_ff: f64,
665    pub b_ff: f64,
666    pub g_ft: f64,
667    pub b_ft: f64,
668    pub g_tf: f64,
669    pub b_tf: f64,
670    pub g_tt: f64,
671    pub b_tt: f64,
672}
673
674/// Canonical branch power flows in per-unit.
675#[derive(Debug, Clone, Copy, PartialEq)]
676pub struct BranchPowerFlowsPu {
677    pub p_from_pu: f64,
678    pub q_from_pu: f64,
679    pub p_to_pu: f64,
680    pub q_to_pu: f64,
681}
682
683impl BranchPowerFlowsPu {
684    #[inline]
685    pub fn s_from_pu(self) -> f64 {
686        (self.p_from_pu * self.p_from_pu + self.q_from_pu * self.q_from_pu).sqrt()
687    }
688
689    #[inline]
690    pub fn s_to_pu(self) -> f64 {
691        (self.p_to_pu * self.p_to_pu + self.q_to_pu * self.q_to_pu).sqrt()
692    }
693
694    #[inline]
695    pub fn max_s_pu(self) -> f64 {
696        self.s_from_pu().max(self.s_to_pu())
697    }
698}
699
700/// Operating regime under which a branch flow rating is being applied.
701///
702/// Different ratings apply in different conditions:
703/// - `Base`: long-term thermal rating (PSS/E `RATE_A`, MATPOWER column 6).
704///   Applied to base-case dispatch decisions.
705/// - `Contingency`: short-term rating (PSS/E `RATE_B`, MATPOWER column 7).
706///   Intermediate tier used by some pipelines when a distinct short-term
707///   rating is populated; falls back to `rating_a` otherwise.
708/// - `Emergency`: emergency / cascading-event rating (PSS/E `RATE_C`,
709///   MATPOWER column 8). GO Competition Challenge 3 §6 eq (271) maps
710///   `s^max,ctg_j → mva_ub_em → rating_c_mva`, so N-1 post-contingency
711///   screening and LODF cut limits should use `Emergency`. The fallback
712///   chain is `rating_c → rating_b → rating_a`, which keeps non-GO
713///   datasets that only populate a subset of the tiers working.
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
715pub enum BranchRatingCondition {
716    Base,
717    Contingency,
718    Emergency,
719}
720
721impl Branch {
722    /// Effective tap ratio, normalizing MATPOWER's tap=0 convention to 1.0.
723    ///
724    /// MATPOWER uses `tap = 0` in the data file to mean "no transformer" (i.e.,
725    /// unity tap ratio). This method returns 1.0 when `|tap| < 1e-10` and the
726    /// stored tap value otherwise.
727    #[inline]
728    pub fn effective_tap(&self) -> f64 {
729        if self.tap.abs() < 1e-10 {
730            1.0
731        } else {
732            self.tap
733        }
734    }
735
736    /// Apparent-power rating (MVA) for the requested operating regime.
737    ///
738    /// Falls back through `Emergency → Contingency → Base` so that branches
739    /// which only carry a long-term `RATE_A` value still get a sensible
740    /// rating in contingency analysis. Datasets that explicitly populate
741    /// `RATE_B` / `RATE_C` get the tighter / looser limit they specify.
742    /// A return value of `0.0` means "no rating provided" — the caller is
743    /// responsible for treating that case as unconstrained or rejecting the
744    /// branch from screening.
745    #[inline]
746    pub fn rating_for(&self, condition: BranchRatingCondition) -> f64 {
747        let nonzero = |value: f64| (value > 0.0).then_some(value);
748        match condition {
749            BranchRatingCondition::Base => self.rating_a_mva,
750            BranchRatingCondition::Contingency => {
751                nonzero(self.rating_b_mva).unwrap_or(self.rating_a_mva)
752            }
753            BranchRatingCondition::Emergency => nonzero(self.rating_c_mva)
754                .or_else(|| nonzero(self.rating_b_mva))
755                .unwrap_or(self.rating_a_mva),
756        }
757    }
758
759    /// True if this branch carries non-zero switching transition costs,
760    /// indicating it is a candidate for on/off optimization when the
761    /// global `allow_branch_switching` flag is set. Branches without
762    /// transition costs are pinned to their static `in_service` state
763    /// even when the flag is `true`.
764    #[inline]
765    pub fn is_switchable(&self) -> bool {
766        self.cost_startup != 0.0 || self.cost_shutdown != 0.0
767    }
768
769    /// True if this branch is a transformer (non-unity tap or non-zero phase shift).
770    #[inline]
771    pub fn is_transformer(&self) -> bool {
772        (self.effective_tap() - 1.0).abs() > 1e-6 || self.phase_shift_rad.abs() > 1e-8
773    }
774
775    /// Pi-model admittance parameters for this branch.
776    ///
777    /// Returns the 8 admittance components `(g_ff, b_ff, g_ft, b_ft, g_tf, b_tf, g_tt, b_tt)`
778    /// used to assemble the Y-bus and compute branch power flows.
779    ///
780    /// `z_sq_tol` is the caller-supplied zero-impedance guard threshold. When `r² + x²`
781    /// is below this value, the branch is treated as a low-impedance tie (gs = 1e6, bs = 0).
782    /// Each call site passes its existing threshold to preserve exact current behavior.
783    ///
784    /// The from-side self-admittance includes `g_pi/2`, the asymmetric
785    /// `g_shunt_from`/`b_shunt_from` additions, and `g_mag`/`b_mag`
786    /// (transformer magnetizing branch at winding-1). The to-side
787    /// self-admittance includes `g_pi/2` and `g_shunt_to`/`b_shunt_to`
788    /// but not the magnetizing terms.
789    ///
790    /// The asymmetric per-side shunts implement GO Competition Challenge 3
791    /// §4.8 eqs (148)-(151), where the from-terminal admittance-to-ground
792    /// is `(g^sr + g^fr_j) − j(b^sr + b^fr_j + b^ch_j/2)` and the
793    /// to-terminal admittance-to-ground is `(g^sr + g^to_j) − j(b^sr + b^to_j + b^ch_j/2)`.
794    /// The surge pi-model expresses this as `g_pi/2 + g_shunt_from` at
795    /// from and `g_pi/2 + g_shunt_to` at to (with analogous susceptance
796    /// splits); GO C3 branches store their symmetric `b^ch` in `b` and
797    /// the per-side deltas in `{g,b}_shunt_{from,to}`. Branches without
798    /// an `additional_shunt` field see the four per-side fields default
799    /// to zero and the admittance collapses to the classic symmetric
800    /// pi-model.
801    #[inline]
802    pub fn pi_model_admittances(&self, z_sq_tol: f64) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
803        let z_sq = self.r * self.r + self.x * self.x;
804        let (gs, bs) = if z_sq > z_sq_tol {
805            (self.r / z_sq, -self.x / z_sq)
806        } else {
807            (1e6, 0.0)
808        };
809
810        let tap = self.effective_tap();
811        let tap_sq = tap * tap;
812        let (cos_s, sin_s) = (self.phase_shift_rad.cos(), self.phase_shift_rad.sin());
813
814        let g_ff = (gs + self.g_pi / 2.0 + self.g_shunt_from) / tap_sq + self.g_mag;
815        let b_ff = (bs + self.b / 2.0 + self.b_shunt_from) / tap_sq + self.b_mag;
816        let g_ft = -(gs * cos_s - bs * sin_s) / tap;
817        let b_ft = -(gs * sin_s + bs * cos_s) / tap;
818        let g_tf = -(gs * cos_s + bs * sin_s) / tap;
819        let b_tf = (gs * sin_s - bs * cos_s) / tap;
820        let g_tt = gs + self.g_pi / 2.0 + self.g_shunt_to;
821        let b_tt = bs + self.b / 2.0 + self.b_shunt_to;
822
823        (g_ff, b_ff, g_ft, b_ft, g_tf, b_tf, g_tt, b_tt)
824    }
825
826    /// Canonical pi-model admittance parameters as a named struct.
827    #[inline]
828    pub fn pi_model(&self, z_sq_tol: f64) -> BranchPiAdmittance {
829        let (g_ff, b_ff, g_ft, b_ft, g_tf, b_tf, g_tt, b_tt) = self.pi_model_admittances(z_sq_tol);
830        BranchPiAdmittance {
831            g_ff,
832            b_ff,
833            g_ft,
834            b_ft,
835            g_tf,
836            b_tf,
837            g_tt,
838            b_tt,
839        }
840    }
841
842    /// Canonical from-end and to-end branch power flows in per-unit.
843    ///
844    /// `theta_ft_rad` is the bus-angle difference `va_from - va_to` in radians.
845    #[inline]
846    pub fn power_flows_pu(
847        &self,
848        vf_pu: f64,
849        vt_pu: f64,
850        theta_ft_rad: f64,
851        z_sq_tol: f64,
852    ) -> BranchPowerFlowsPu {
853        let adm = self.pi_model(z_sq_tol);
854        let (sin_ft, cos_ft) = theta_ft_rad.sin_cos();
855        let theta_tf_rad = -theta_ft_rad;
856        let (sin_tf, cos_tf) = theta_tf_rad.sin_cos();
857
858        BranchPowerFlowsPu {
859            p_from_pu: vf_pu * vf_pu * adm.g_ff
860                + vf_pu * vt_pu * (adm.g_ft * cos_ft + adm.b_ft * sin_ft),
861            q_from_pu: -vf_pu * vf_pu * adm.b_ff
862                + vf_pu * vt_pu * (adm.g_ft * sin_ft - adm.b_ft * cos_ft),
863            p_to_pu: vt_pu * vt_pu * adm.g_tt
864                + vt_pu * vf_pu * (adm.g_tf * cos_tf + adm.b_tf * sin_tf),
865            q_to_pu: -vt_pu * vt_pu * adm.b_tt
866                + vt_pu * vf_pu * (adm.g_tf * sin_tf - adm.b_tf * cos_tf),
867        }
868    }
869
870    /// DC series susceptance, corrected for off-nominal tap ratio.
871    ///
872    /// MATPOWER convention: TAP = 0 in the data file means "no transformer" and
873    /// is treated as tap = 1.0.  For transformers with non-zero tap, the effective
874    /// DC susceptance is 1 / (x * tap), matching MATPOWER's `makeBdc`.
875    ///
876    /// Returns the **signed** susceptance: b = 1 / (x * tap).
877    /// Negative reactance (series compensation) correctly produces negative b,
878    /// which is the physically accurate value for B-theta DC power flow and
879    /// DC-OPF B-matrix assembly (matches MATPOWER `makeBdc` exactly — no abs).
880    ///
881    /// Branches with |x*tap| < 1e-20 (true zero-impedance ties) return 0.0
882    /// to avoid division-by-zero; callers that need tie-line treatment should
883    /// handle this case explicitly.
884    ///
885    /// The threshold is intentionally very small to match MATPOWER's `makeBdc`
886    /// which computes `b = 1/x` with no clipping.  Real branches may have very
887    /// small per-unit reactances (e.g. 6e-10 after ohm-to-pu conversion) and
888    /// must not be zeroed out.
889    #[inline]
890    pub fn b_dc(&self) -> f64 {
891        let tap = self.effective_tap();
892        let denom = self.x * tap;
893        if denom.abs() < 1e-20 {
894            0.0
895        } else {
896            1.0 / denom
897        }
898    }
899
900    /// Check whether a given angle difference (in **radians**) violates this
901    /// branch's angle limits.
902    ///
903    /// `angle_diff_rad` should be `va_from - va_to` in radians (matching the
904    /// Newton-Raphson voltage angle convention).
905    ///
906    /// Returns `true` if the angle difference is outside `[angmin, angmax]`.
907    /// If either limit is `None`, that side is unconstrained.
908    #[inline]
909    pub fn angle_diff_violates(&self, angle_diff_rad: f64) -> bool {
910        if let Some(lo) = self.angle_diff_min_rad {
911            debug_assert!(
912                lo.abs() <= 2.0 * std::f64::consts::PI + 0.01,
913                "angle_diff_min_rad appears to be in degrees ({lo}), expected radians"
914            );
915            if angle_diff_rad < lo {
916                return true;
917            }
918        }
919        if let Some(hi) = self.angle_diff_max_rad {
920            debug_assert!(
921                hi.abs() <= 2.0 * std::f64::consts::PI + 0.01,
922                "angle_diff_max_rad appears to be in degrees ({hi}), expected radians"
923            );
924            if angle_diff_rad > hi {
925                return true;
926            }
927        }
928        false
929    }
930
931    pub fn new_line(from_bus: u32, to_bus: u32, r: f64, x: f64, b: f64) -> Self {
932        Self {
933            from_bus,
934            to_bus,
935            r,
936            x,
937            b,
938            ..Default::default()
939        }
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    /// Helper: create a branch with the given x and tap, everything else defaulted.
948    fn branch_with(x: f64, tap: f64) -> Branch {
949        Branch {
950            from_bus: 1,
951            to_bus: 2,
952            r: 0.01,
953            x,
954            rating_a_mva: 100.0,
955            rating_b_mva: 100.0,
956            rating_c_mva: 100.0,
957            tap,
958            ..Default::default()
959        }
960    }
961
962    #[test]
963    fn test_b_dc_simple_line() {
964        // Line with x=0.1, tap=1.0 => b_dc = 1 / (0.1 * 1.0) = 10.0
965        let br = branch_with(0.1, 1.0);
966        assert!((br.b_dc() - 10.0).abs() < 1e-10);
967    }
968
969    #[test]
970    fn test_b_dc_with_tap() {
971        // Transformer with tap=1.05, x=0.1 => b_dc = 1 / (0.1 * 1.05)
972        let br = branch_with(0.1, 1.05);
973        let expected = 1.0 / (0.1 * 1.05);
974        assert!(
975            (br.b_dc() - expected).abs() < 1e-10,
976            "b_dc with tap=1.05: got {}, expected {}",
977            br.b_dc(),
978            expected
979        );
980    }
981
982    #[test]
983    fn test_b_dc_tap_zero_treated_as_one() {
984        // MATPOWER convention: tap=0 in the data file means "no transformer",
985        // treated as tap=1.0.  b_dc should equal 1/x = 1/0.1 = 10.0.
986        let br = branch_with(0.1, 0.0);
987        assert!(
988            (br.b_dc() - 10.0).abs() < 1e-10,
989            "tap=0 should be treated as tap=1.0; got b_dc={}",
990            br.b_dc()
991        );
992    }
993
994    #[test]
995    fn test_b_dc_zero_x() {
996        // Zero-impedance tie line: x=0, tap=1.0
997        // The implementation returns 0.0 when |x*tap| < 1e-20.
998        let br = branch_with(0.0, 1.0);
999        assert!(
1000            br.b_dc().abs() < 1e-10,
1001            "zero-impedance branch should return b_dc=0.0; got {}",
1002            br.b_dc()
1003        );
1004    }
1005
1006    #[test]
1007    fn test_b_dc_negative_x() {
1008        // Series capacitor: negative reactance x = -0.05, tap=1.0
1009        // b_dc = 1 / (-0.05 * 1.0) = -20.0  (signed, physically correct)
1010        let br = branch_with(-0.05, 1.0);
1011        let expected = 1.0 / (-0.05);
1012        assert!(
1013            (br.b_dc() - expected).abs() < 1e-10,
1014            "series capacitor b_dc: got {}, expected {}",
1015            br.b_dc(),
1016            expected
1017        );
1018    }
1019
1020    #[test]
1021    fn test_new_line_defaults() {
1022        let br = Branch::new_line(5, 10, 0.01, 0.1, 0.02);
1023        assert_eq!(br.from_bus, 5);
1024        assert_eq!(br.to_bus, 10);
1025        assert_eq!(br.circuit, "1");
1026        assert!((br.r - 0.01).abs() < 1e-15);
1027        assert!((br.x - 0.1).abs() < 1e-15);
1028        assert!((br.b - 0.02).abs() < 1e-15);
1029        assert!(
1030            (br.tap - 1.0).abs() < 1e-15,
1031            "new_line tap should default to 1.0"
1032        );
1033        assert!(
1034            (br.phase_shift_rad).abs() < 1e-15,
1035            "new_line phase_shift_rad should default to 0.0"
1036        );
1037        assert!(br.in_service, "new_line should be in service by default");
1038        assert!(
1039            (br.rating_a_mva).abs() < 1e-15,
1040            "new_line rating_a_mva should default to 0.0"
1041        );
1042        assert!(
1043            br.angle_diff_min_rad.is_none(),
1044            "new_line angle_diff_min_rad should be None"
1045        );
1046        assert!(
1047            br.angle_diff_max_rad.is_none(),
1048            "new_line angle_diff_max_rad should be None"
1049        );
1050        // Moved fields: sub-structs default to None for new lines.
1051        assert!(br.zero_seq.is_none(), "new_line zero_seq should be None");
1052        assert!(br.harmonic.is_none(), "new_line harmonic should be None");
1053        assert!(
1054            br.transformer_data.is_none(),
1055            "new_line transformer_data should be None"
1056        );
1057    }
1058}