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}