1use serde::{Deserialize, Serialize};
4
5pub const MIN_DIVISIBLE_MAGNITUDE: f64 = 1.491_668_146_240_041_3e-154;
14
15pub(crate) fn series_admittance_parts(r: f64, x: f64) -> (f64, f64) {
27 let denom = r * r + x * x;
28 if denom.is_finite() {
29 return (r / denom, -x / denom);
30 }
31 let scale = r.abs().max(x.abs());
32 let (r, x) = (r / scale, x / scale);
33 let denom = (r * r + x * x) * scale;
34 (r / denom, -x / denom)
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[non_exhaustive]
45pub enum DcConvention {
46 ReactanceOnly,
51 #[serde(alias = "Matpower")]
57 TapAdjustedReactance,
58 #[default]
68 #[serde(alias = "SeriesImpedance")]
69 SeriesSusceptance,
70}
71
72impl DcConvention {
73 #[must_use]
84 pub fn branch_susceptance(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
85 let negated_reciprocal = |denominator: f64| {
88 if denominator.is_finite() {
89 -1.0 / denominator
90 } else {
91 f64::NAN
92 }
93 };
94 match self {
95 Self::ReactanceOnly => negated_reciprocal(reactance),
96 Self::TapAdjustedReactance => negated_reciprocal(reactance * effective_tap),
97 Self::SeriesSusceptance => series_admittance_parts(resistance, reactance).1,
98 }
99 }
100
101 #[must_use]
108 pub fn solver_edge_weight(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
109 -self.branch_susceptance(resistance, reactance, effective_tap)
110 }
111
112 #[must_use]
115 pub fn reads_tap(self) -> bool {
116 matches!(self, Self::TapAdjustedReactance)
117 }
118
119 #[must_use]
121 pub fn includes_phase_shifts(self) -> bool {
122 match self {
123 Self::ReactanceOnly => false,
124 Self::TapAdjustedReactance | Self::SeriesSusceptance => true,
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn three_bus_network() -> crate::BalancedNetwork {
134 use crate::{Branch, Bus, BusId, BusType};
135 let mut shifted = Branch::new(BusId(2), BusId(3), 0.0, 0.2);
136 shifted.shift = 30.0;
137 let mut out = Branch::new(BusId(1), BusId(3), 0.01, 0.1);
138 out.in_service = false;
139 crate::BalancedNetwork::in_memory(
140 "dc-data",
141 100.0,
142 vec![
143 Bus::new(BusId(1), BusType::Ref, 230.0),
144 Bus::new(BusId(2), BusType::Pq, 230.0),
145 Bus::new(BusId(3), BusType::Pq, 230.0),
146 ],
147 vec![Branch::new(BusId(1), BusId(2), 0.0, 0.1), shifted, out],
148 )
149 }
150
151 #[test]
155 fn dc_network_data_maps_rows_and_omissions() {
156 let network = three_bus_network();
157 let view = crate::IndexedNetwork::new(&network);
158 let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
159 assert_eq!(data.formula, "series_susceptance");
160 assert_eq!(data.from_indices, vec![0, 1]);
161 assert_eq!(data.to_indices, vec![1, 2]);
162 assert_eq!(data.row_ids, vec!["branches:0", "branches:1"]);
163 assert_eq!(data.bus_ids, vec!["1", "2", "3"]);
164 assert_eq!(data.omitted.len(), 1);
165 assert_eq!(data.omitted[0].0, "branches:2");
166 assert!(data.omitted[0].1.contains("out of service"));
167
168 let b = data.susceptance[1];
169 assert!((b + 5.0).abs() < 1e-12);
171 let shift = 30.0_f64.to_radians();
172 assert!(data.shift[0].abs() < 1e-15);
173 assert!((data.shift[1] - shift).abs() < 1e-12);
174 assert!((data.shift_injection[1] - (b * shift)).abs() < 1e-12);
177 assert!((data.shift_injection[2] - (-b * shift)).abs() < 1e-12);
178 assert!(data.shift_injection[0].abs() < 1e-15);
179 let p_branch = -b * 0.0 + b * shift;
182 assert!((p_branch - 5.0 * (0.0 - shift)).abs() < 1e-12);
183 }
184
185 #[test]
189 fn every_branch_is_included_or_omitted_exactly_once() {
190 use crate::{Branch, Bus, BusId, BusType};
191 let mut branches = vec![
192 Branch::new(BusId(1), BusId(2), 0.0, 0.1),
193 Branch::new(BusId(2), BusId(2), 0.0, 0.1),
194 Branch::new(BusId(1), BusId(9), 0.01, 0.1),
195 Branch::new(BusId(1), BusId(3), 0.0, 0.0),
196 Branch::new(BusId(2), BusId(3), 0.0, f64::NAN),
197 Branch::new(BusId(1), BusId(3), 0.02, 0.2),
198 ];
199 branches[5].in_service = false;
200 let mut giant_tap = Branch::new(BusId(2), BusId(3), 0.0, 1.0e308);
201 giant_tap.tap = 1.0e308;
202 branches.push(giant_tap);
203 let network = crate::BalancedNetwork::in_memory(
204 "partition",
205 100.0,
206 vec![
207 Bus::new(BusId(1), BusType::Ref, 230.0),
208 Bus::new(BusId(2), BusType::Pq, 230.0),
209 Bus::new(BusId(3), BusType::Pq, 230.0),
210 ],
211 branches,
212 );
213 let view = crate::IndexedNetwork::new(&network);
214 for convention in [
215 DcConvention::SeriesSusceptance,
216 DcConvention::TapAdjustedReactance,
217 DcConvention::ReactanceOnly,
218 ] {
219 let data = dc_network_data(&view, convention);
220 let included = data.row_ids.len();
221 assert_eq!(included, data.susceptance.len());
222 assert_eq!(included, data.from_indices.len());
223 assert_eq!(
224 included + data.omitted.len(),
225 network.branches().len(),
226 "{convention:?}"
227 );
228 let mut ids: Vec<&str> = data
229 .row_ids
230 .iter()
231 .map(String::as_str)
232 .chain(data.omitted.iter().map(|(id, _)| id.as_str()))
233 .collect();
234 ids.sort_unstable();
235 ids.dedup();
236 assert_eq!(ids.len(), network.branches().len(), "{convention:?}");
237 assert!(data.susceptance.iter().all(|b| b.is_finite()));
238 }
239 }
240
241 #[test]
246 fn the_degeneracy_bound_follows_the_formula() {
247 use crate::{Branch, Bus, BusId, BusType};
248 let mut resistive = Branch::new(BusId(1), BusId(2), 0.05, 0.0);
249 resistive.uid = Some("resistive".to_owned());
250 let mut nothing = Branch::new(BusId(2), BusId(3), 0.0, 0.0);
251 nothing.uid = Some("nothing".to_owned());
252 let network = crate::BalancedNetwork::in_memory(
253 "dc-degenerate",
254 100.0,
255 vec![
256 Bus::new(BusId(1), BusType::Ref, 230.0),
257 Bus::new(BusId(2), BusType::Pq, 230.0),
258 Bus::new(BusId(3), BusType::Pq, 230.0),
259 ],
260 vec![resistive, nothing],
261 );
262 let view = crate::IndexedNetwork::new(&network);
263
264 let series = dc_network_data(&view, DcConvention::SeriesSusceptance);
265 assert_eq!(series.row_ids, vec!["resistive"]);
266 assert_eq!(series.from_indices, vec![0]);
267 assert_eq!(series.to_indices, vec![1]);
268 assert!(series.susceptance[0].abs() < 1e-15);
269 assert_eq!(series.omitted.len(), 1);
270 assert_eq!(series.omitted[0].0, "nothing");
271
272 for convention in [
273 DcConvention::TapAdjustedReactance,
274 DcConvention::ReactanceOnly,
275 ] {
276 let data = dc_network_data(&view, convention);
277 assert!(data.row_ids.is_empty(), "{convention:?}");
278 let omitted: Vec<&str> = data.omitted.iter().map(|(id, _)| id.as_str()).collect();
279 assert_eq!(omitted, vec!["resistive", "nothing"], "{convention:?}");
280 for (_, reason) in &data.omitted {
281 assert!(reason.contains("reactance"), "{reason}");
282 }
283 }
284 }
285
286 #[test]
290 fn three_winding_expansion_keeps_every_table_aligned() {
291 let path = concat!(
292 env!("CARGO_MANIFEST_DIR"),
293 "/../tests/data/psse/case3_3w_v33.raw"
294 );
295 let source = powerio_core::Source::open(std::path::Path::new(path)).unwrap();
296 let module =
297 crate::parse(source.with_format(powerio_core::FormatId::new("psse").unwrap())).unwrap();
298 let network = module.value();
299 let view = crate::IndexedNetwork::new(network);
300 let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
301 assert_eq!(data.bus_ids.len(), view.n());
302 assert_eq!(data.bus_ids.len(), 4);
304 assert!(
305 data.row_ids.len() + data.omitted.len() >= 3,
306 "winding branches missing: {} rows, {} omitted",
307 data.row_ids.len(),
308 data.omitted.len()
309 );
310 for index in &data.from_indices {
311 assert!(*index < data.bus_ids.len());
312 }
313 for index in &data.to_indices {
314 assert!(*index < data.bus_ids.len());
315 }
316 }
317
318 #[test]
321 fn formula_names_round_trip() {
322 for convention in [
323 DcConvention::SeriesSusceptance,
324 DcConvention::TapAdjustedReactance,
325 DcConvention::ReactanceOnly,
326 ] {
327 assert_eq!(
328 DcConvention::from_formula_name(convention.formula_name()),
329 Some(convention)
330 );
331 }
332 assert_eq!(DcConvention::from_formula_name("mystery"), None);
333 }
334
335 #[test]
340 fn series_susceptance_reduces_to_negated_one_over_x() {
341 let b = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.25, 1.0);
342 assert!((b + 4.0).abs() < 1e-12);
343 let weight = DcConvention::SeriesSusceptance.solver_edge_weight(0.0, 0.25, 1.0);
345 assert!((weight - 4.0).abs() < 1e-12);
346 }
347
348 #[test]
351 fn resistance_lowers_the_susceptance_magnitude() {
352 let lossless = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.1, 1.0);
353 let lossy = DcConvention::SeriesSusceptance.branch_susceptance(0.1, 0.1, 1.0);
354 assert!(lossy.abs() < lossless.abs());
355 assert!((lossy + 5.0).abs() < 1e-12);
356 }
357
358 #[test]
359 fn matpower_scales_by_the_tap() {
360 let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.01, 0.2, 2.0);
361 assert!((b + 2.5).abs() < 1e-12);
362 }
363
364 #[test]
367 fn an_unread_tap_never_rejects_a_branch() {
368 for conv in [DcConvention::ReactanceOnly, DcConvention::SeriesSusceptance] {
369 assert!(!conv.reads_tap());
370 let b = conv.branch_susceptance(0.01, 0.1, 1e-200);
371 assert!(b.is_finite(), "{conv:?} read the tap it never divides by");
372 }
373 assert!(DcConvention::TapAdjustedReactance.reads_tap());
374 }
375
376 #[test]
381 fn a_non_finite_denominator_is_not_a_susceptance() {
382 for x in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
383 for conv in [
384 DcConvention::ReactanceOnly,
385 DcConvention::TapAdjustedReactance,
386 DcConvention::SeriesSusceptance,
387 ] {
388 let b = conv.branch_susceptance(0.01, x, 1.0);
389 assert!(!b.is_finite(), "{conv:?} read x = {x} as b = {b}");
390 }
391 }
392 for (x, tap) in [
393 (0.1, f64::INFINITY),
394 (0.1, f64::NAN),
395 (1e300, 1e300),
396 (1e300, -1e300),
397 ] {
398 let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.0, x, tap);
399 assert!(!b.is_finite(), "x = {x}, tap = {tap} read as b = {b}");
400 }
401 }
402
403 #[test]
409 fn an_impedance_whose_square_overflows_still_has_a_susceptance() {
410 let (r, x) = (1e160, 1e160);
411 assert!(r * r + x * x == f64::INFINITY, "the direct form overflows");
412
413 let b = DcConvention::SeriesSusceptance.branch_susceptance(r, x, 1.0);
414 assert!(
416 (b / -5e-161 - 1.0).abs() < 1e-12,
417 "the branch is not dropped, got {b}"
418 );
419
420 let (g, susceptance) = series_admittance_parts(r, x);
421 assert!((g / 5e-161 - 1.0).abs() < 1e-12, "got {g}");
422 assert!(
423 (susceptance - b).abs() < 1e-175,
424 "the public rule is the series susceptance itself"
425 );
426 }
427
428 #[test]
431 fn the_ordinary_range_is_bit_identical_to_the_direct_quotient() {
432 for (r, x) in [
433 (0.01, 0.1),
434 (0.03, 0.04),
435 (0.0, 0.25),
436 (1e-6, 1e-5),
437 (7.0, 3.0),
438 ] {
439 let denom = r * r + x * x;
440 assert_eq!(series_admittance_parts(r, x), (r / denom, -x / denom));
441 }
442 }
443}
444
445#[derive(Clone, Debug, PartialEq)]
460#[non_exhaustive]
461pub struct DcNetworkData {
462 pub from_indices: Vec<usize>,
464 pub to_indices: Vec<usize>,
466 pub susceptance: Vec<f64>,
468 pub shift: Vec<f64>,
471 pub shift_injection: Vec<f64>,
473 pub row_ids: Vec<String>,
475 pub bus_ids: Vec<String>,
477 pub omitted: Vec<(String, String)>,
481 pub formula: &'static str,
483}
484
485impl DcConvention {
486 #[must_use]
488 pub fn formula_name(self) -> &'static str {
489 match self {
490 Self::SeriesSusceptance => "series_susceptance",
491 Self::TapAdjustedReactance => "tap_adjusted_reactance",
492 Self::ReactanceOnly => "reactance_only",
493 }
494 }
495
496 #[must_use]
499 pub fn from_formula_name(name: &str) -> Option<Self> {
500 match name {
501 "series_susceptance" | "series" => Some(Self::SeriesSusceptance),
502 "tap_adjusted_reactance" | "matpower" => Some(Self::TapAdjustedReactance),
503 "reactance_only" => Some(Self::ReactanceOnly),
504 _ => None,
505 }
506 }
507}
508
509#[must_use]
517pub fn dc_network_data(
518 view: &crate::IndexedNetwork<'_>,
519 convention: DcConvention,
520) -> DcNetworkData {
521 let network = view.network();
522 let n = view.n();
523 let mut data = DcNetworkData {
524 from_indices: Vec::new(),
525 to_indices: Vec::new(),
526 susceptance: Vec::new(),
527 shift: Vec::new(),
528 shift_injection: vec![0.0; n],
529 row_ids: Vec::new(),
530 bus_ids: network
531 .buses()
532 .iter()
533 .map(|bus| bus.id.0.to_string())
534 .collect(),
535 omitted: Vec::new(),
536 formula: convention.formula_name(),
537 };
538 for (idx, branch) in network.branches().iter().enumerate() {
539 let id = branch
540 .uid
541 .clone()
542 .unwrap_or_else(|| format!("branches:{idx}"));
543 if !branch.in_service {
544 data.omitted.push((id, "out of service".to_owned()));
545 continue;
546 }
547 let (Some(i), Some(j)) = (view.bus_index(branch.from), view.bus_index(branch.to)) else {
548 data.omitted
549 .push((id, "references an undeclared bus".to_owned()));
550 continue;
551 };
552 if i == j {
553 data.omitted.push((id, "self loop".to_owned()));
554 continue;
555 }
556 let degenerate = match convention {
557 DcConvention::SeriesSusceptance => branch.r.hypot(branch.x) < MIN_DIVISIBLE_MAGNITUDE,
558 DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
559 branch.x.abs() < MIN_DIVISIBLE_MAGNITUDE
560 }
561 };
562 if degenerate {
563 let reason = match convention {
564 DcConvention::SeriesSusceptance => {
565 "zero impedance: the series impedance magnitude is below the divisibility \
566 floor"
567 }
568 DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
569 "zero reactance: the selected formula divides by reactance"
570 }
571 };
572 data.omitted.push((id, reason.to_owned()));
573 continue;
574 }
575 let tap = match branch.divisible_tap(idx) {
576 Ok(tap) => tap,
577 Err(error) => {
578 data.omitted.push((id, error.to_string()));
579 continue;
580 }
581 };
582 let b = convention.branch_susceptance(branch.r, branch.x, tap);
583 if !b.is_finite() {
584 data.omitted
585 .push((id, "susceptance is not finite".to_owned()));
586 continue;
587 }
588 let row_shift = if convention.includes_phase_shifts() {
589 view.angle_radians(branch.shift)
590 } else {
591 0.0
592 };
593 if row_shift != 0.0 {
594 data.shift_injection[i] += b * row_shift;
595 data.shift_injection[j] -= b * row_shift;
596 }
597 data.from_indices.push(i);
598 data.to_indices.push(j);
599 data.susceptance.push(b);
600 data.shift.push(row_shift);
601 data.row_ids.push(id);
602 }
603 data
604}