1use std::collections::{BTreeMap, BTreeSet};
8
9use num_complex::Complex64;
10use powerio_prob::{LinDist3FlowNode, LinDist3FlowOpfInstance};
11
12use crate::{Error, Result};
13
14#[derive(Clone, Copy, Debug, PartialEq)]
16#[non_exhaustive]
17pub struct CrossVoltageCoefficients {
18 pub constant: Complex64,
19 pub coefficient_phi: Complex64,
20 pub coefficient_psi: Complex64,
21}
22
23#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct AffineScalarCoefficients {
27 pub constant: f64,
28 pub coefficients: Vec<f64>,
29}
30
31#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct ConnectionPowerMap {
35 pub matrix: Vec<Vec<Complex64>>,
37 pub real_part: Vec<Vec<f64>>,
38 pub imag_part: Vec<Vec<f64>>,
39 pub reference_winding_voltage: Vec<Complex64>,
41}
42
43#[derive(Clone, Debug, PartialEq)]
45#[non_exhaustive]
46pub struct LineDropCoefficients {
47 pub active: Vec<Vec<f64>>,
49 pub reactive: Vec<Vec<f64>>,
51}
52
53#[derive(Clone, Debug, PartialEq)]
55#[non_exhaustive]
56pub struct LinDist3FlowNodeData {
57 pub node: LinDist3FlowNode,
58 pub reference_magnitude: f64,
59 pub reference_angle: f64,
60 pub squared_voltage_min: Option<f64>,
61 pub squared_voltage_max: Option<f64>,
62 pub fixed_squared_voltage: Option<f64>,
64}
65
66#[derive(Clone, Debug, PartialEq)]
68#[non_exhaustive]
69pub struct LinDist3FlowLineData {
70 pub line: String,
71 pub source_line_row: usize,
72 pub parent_nodes: Vec<usize>,
73 pub child_nodes: Vec<usize>,
74 pub reversed: bool,
75 pub drop: LineDropCoefficients,
76 pub current_limit: Vec<Option<f64>>,
78 pub apparent_power_limit: Vec<Option<f64>>,
80}
81
82#[derive(Clone, Debug, PartialEq)]
88#[non_exhaustive]
89pub struct LinDist3FlowNetworkData {
90 pub nodes: Vec<LinDist3FlowNodeData>,
91 pub lines: Vec<LinDist3FlowLineData>,
92}
93
94fn invalid(reason: impl Into<String>) -> Error {
95 Error::InvalidLinDist3FlowCoefficients {
96 reason: reason.into(),
97 }
98}
99
100fn valid_complex(value: Complex64) -> bool {
101 value.re.is_finite() && value.im.is_finite()
102}
103
104fn valid_reference(value: Complex64) -> bool {
105 valid_complex(value) && value.norm_sqr() > 0.0
106}
107
108fn validate_reference(reference: &[Complex64], label: &str) -> Result<()> {
109 if reference.is_empty() {
110 return Err(invalid(format!("{label} must not be empty")));
111 }
112 if let Some(position) = reference.iter().position(|value| !valid_reference(*value)) {
113 return Err(invalid(format!(
114 "{label} entry {position} must be finite and nonzero"
115 )));
116 }
117 Ok(())
118}
119
120fn validate_real_row(row: &[f64], expected: usize, label: &str, position: usize) -> Result<()> {
121 if row.len() != expected {
122 return Err(invalid(format!(
123 "{label} row {position} has length {}, expected {expected}",
124 row.len()
125 )));
126 }
127 if row.iter().any(|value| !value.is_finite()) {
128 return Err(invalid(format!(
129 "{label} row {position} contains a non-finite value"
130 )));
131 }
132 Ok(())
133}
134
135fn squared_bound(value: Option<f64>, label: &str) -> Result<Option<f64>> {
136 value
137 .map(|value| {
138 if !value.is_finite() || value < 0.0 {
139 return Err(invalid(format!("{label} must be finite and nonnegative")));
140 }
141 Ok(value * value)
142 })
143 .transpose()
144}
145
146fn terminal_bounds(
147 bus: &powerio_dist::DistBus,
148 position: usize,
149) -> Result<(Option<f64>, Option<f64>)> {
150 let lower = if bus.v_min.is_some() {
151 bus.v_min
152 } else if let Some(values) = &bus.v_min_phase {
153 if values.len() != bus.terminals.len() {
154 return Err(invalid(format!(
155 "bus `{}` phase voltage lower bounds have length {}, expected {}",
156 bus.id,
157 values.len(),
158 bus.terminals.len()
159 )));
160 }
161 Some(values[position])
162 } else {
163 None
164 };
165 let upper = if bus.v_max.is_some() {
166 bus.v_max
167 } else if let Some(values) = &bus.v_max_phase {
168 if values.len() != bus.terminals.len() {
169 return Err(invalid(format!(
170 "bus `{}` phase voltage upper bounds have length {}, expected {}",
171 bus.id,
172 values.len(),
173 bus.terminals.len()
174 )));
175 }
176 Some(values[position])
177 } else {
178 None
179 };
180 Ok((
181 squared_bound(lower, "voltage lower bound")?,
182 squared_bound(upper, "voltage upper bound")?,
183 ))
184}
185
186fn optional_ratings(
187 values: Option<&[f64]>,
188 conductors: usize,
189 label: &str,
190) -> Result<Vec<Option<f64>>> {
191 let Some(values) = values else {
192 return Ok(vec![None; conductors]);
193 };
194 if values.len() != conductors {
195 return Err(invalid(format!(
196 "{label} has length {}, expected {conductors}",
197 values.len()
198 )));
199 }
200 if let Some(position) = values
201 .iter()
202 .position(|value| !value.is_finite() || *value <= 0.0)
203 {
204 return Err(invalid(format!(
205 "{label} entry {position} must be finite and positive"
206 )));
207 }
208 Ok(values.iter().copied().map(Some).collect())
209}
210
211pub fn cross_voltage_coefficients(
220 reference_phi: Complex64,
221 reference_psi: Complex64,
222) -> Result<CrossVoltageCoefficients> {
223 if !valid_reference(reference_phi) || !valid_reference(reference_psi) {
224 return Err(invalid(
225 "cross-voltage reference phasors must be finite and nonzero",
226 ));
227 }
228 let coefficient_phi = reference_psi.conj() / (2.0 * reference_phi.conj());
229 let coefficient_psi = reference_phi / (2.0 * reference_psi);
230 let constant = reference_phi * reference_psi.conj()
231 - coefficient_phi * reference_phi.norm_sqr()
232 - coefficient_psi * reference_psi.norm_sqr();
233 Ok(CrossVoltageCoefficients {
234 constant,
235 coefficient_phi,
236 coefficient_psi,
237 })
238}
239
240#[must_use]
242pub fn evaluate_cross_voltage(
243 coefficients: &CrossVoltageCoefficients,
244 w_phi: f64,
245 w_psi: f64,
246) -> Complex64 {
247 coefficients.constant
248 + coefficients.coefficient_phi * w_phi
249 + coefficients.coefficient_psi * w_psi
250}
251
252pub fn winding_voltage_coefficients(
258 incidence: &[f64],
259 reference: &[Complex64],
260) -> Result<AffineScalarCoefficients> {
261 validate_reference(reference, "winding reference")?;
262 if incidence.len() != reference.len() {
263 return Err(invalid(format!(
264 "winding incidence has length {}, expected {}",
265 incidence.len(),
266 reference.len()
267 )));
268 }
269 if incidence.iter().any(|value| !value.is_finite()) {
270 return Err(invalid("winding incidence contains a non-finite value"));
271 }
272 let mut constant = 0.0;
273 let mut coefficients = vec![0.0; reference.len()];
274 for phi in 0..reference.len() {
275 for psi in 0..reference.len() {
276 let scale = incidence[phi] * incidence[psi];
277 if scale.abs() <= f64::EPSILON {
278 continue;
279 }
280 let cross = cross_voltage_coefficients(reference[phi], reference[psi])?;
281 constant += scale * cross.constant.re;
282 coefficients[phi] += scale * cross.coefficient_phi.re;
283 coefficients[psi] += scale * cross.coefficient_psi.re;
284 }
285 }
286 Ok(AffineScalarCoefficients {
287 constant,
288 coefficients,
289 })
290}
291
292pub fn evaluate_affine(coefficients: &AffineScalarCoefficients, values: &[f64]) -> Result<f64> {
297 if coefficients.coefficients.len() != values.len() {
298 return Err(invalid(format!(
299 "affine value vector has length {}, expected {}",
300 values.len(),
301 coefficients.coefficients.len()
302 )));
303 }
304 Ok(coefficients.constant
305 + coefficients
306 .coefficients
307 .iter()
308 .zip(values)
309 .map(|(coefficient, value)| coefficient * value)
310 .sum::<f64>())
311}
312
313pub fn connection_power_map(
322 incidence: &[Vec<f64>],
323 reference: &[Complex64],
324) -> Result<ConnectionPowerMap> {
325 validate_reference(reference, "connection reference")?;
326 if incidence.is_empty() {
327 return Err(invalid(
328 "connection incidence must have at least one channel",
329 ));
330 }
331 for (position, row) in incidence.iter().enumerate() {
332 validate_real_row(row, reference.len(), "connection incidence", position)?;
333 }
334 let reference_winding_voltage = incidence
335 .iter()
336 .map(|row| {
337 row.iter()
338 .zip(reference)
339 .map(|(entry, voltage)| *voltage * *entry)
340 .sum::<Complex64>()
341 })
342 .collect::<Vec<_>>();
343 if let Some(position) = reference_winding_voltage
344 .iter()
345 .position(|value| !valid_reference(*value))
346 {
347 return Err(invalid(format!(
348 "connection channel {position} has a zero or non-finite reference winding voltage"
349 )));
350 }
351
352 let mut matrix = vec![vec![Complex64::new(0.0, 0.0); incidence.len()]; reference.len()];
353 for terminal in 0..reference.len() {
354 for channel in 0..incidence.len() {
355 matrix[terminal][channel] = reference[terminal] * incidence[channel][terminal]
356 / reference_winding_voltage[channel];
357 }
358 }
359 let real_part = matrix
360 .iter()
361 .map(|row| row.iter().map(|value| value.re).collect())
362 .collect();
363 let imag_part = matrix
364 .iter()
365 .map(|row| row.iter().map(|value| value.im).collect())
366 .collect();
367 Ok(ConnectionPowerMap {
368 matrix,
369 real_part,
370 imag_part,
371 reference_winding_voltage,
372 })
373}
374
375pub fn line_drop_coefficients(
385 impedance: &[Vec<Complex64>],
386 reference_from: &[Complex64],
387) -> Result<LineDropCoefficients> {
388 validate_reference(reference_from, "line reference")?;
389 if impedance.len() != reference_from.len() {
390 return Err(invalid(format!(
391 "line impedance has {} rows, expected {}",
392 impedance.len(),
393 reference_from.len()
394 )));
395 }
396 let n = reference_from.len();
397 for (position, row) in impedance.iter().enumerate() {
398 if row.len() != n {
399 return Err(invalid(format!(
400 "line impedance row {position} has length {}, expected {n}",
401 row.len()
402 )));
403 }
404 if row.iter().any(|value| !valid_complex(*value)) {
405 return Err(invalid(format!(
406 "line impedance row {position} contains a non-finite value"
407 )));
408 }
409 }
410 let mut active = vec![vec![0.0; n]; n];
411 let mut reactive = vec![vec![0.0; n]; n];
412 for phi in 0..n {
413 for psi in 0..n {
414 let value = impedance[phi][psi].conj() * reference_from[phi] / reference_from[psi];
415 active[phi][psi] = 2.0 * value.re;
416 reactive[phi][psi] = -2.0 * value.im;
417 }
418 }
419 Ok(LineDropCoefficients { active, reactive })
420}
421
422#[allow(clippy::too_many_lines)]
434pub fn build_lindist3flow_network_data(
435 instance: &LinDist3FlowOpfInstance,
436) -> Result<LinDist3FlowNetworkData> {
437 let network = instance.network();
438 let mut node_positions = BTreeMap::new();
439 for (position, node) in instance.topology().nodes.iter().enumerate() {
440 node_positions.insert(
441 (node.bus.to_ascii_lowercase(), node.terminal.clone()),
442 position,
443 );
444 }
445 let source_nodes = network
446 .sources()
447 .iter()
448 .flat_map(|source| {
449 source
450 .terminal_map
451 .iter()
452 .map(move |terminal| (source.bus.to_ascii_lowercase(), terminal.clone()))
453 })
454 .collect::<BTreeSet<_>>();
455 let buses = network
456 .buses()
457 .iter()
458 .map(|bus| (bus.id.to_ascii_lowercase(), bus))
459 .collect::<BTreeMap<_, _>>();
460
461 let mut nodes = Vec::with_capacity(instance.topology().nodes.len());
462 for node in &instance.topology().nodes {
463 let reference = instance
464 .reference()
465 .voltage(&node.bus, &node.terminal)
466 .ok_or_else(|| {
467 invalid(format!(
468 "reference has no voltage for `{}/{}`",
469 node.bus, node.terminal
470 ))
471 })?;
472 let bus = buses
473 .get(&node.bus.to_ascii_lowercase())
474 .ok_or_else(|| invalid(format!("topology names unknown bus `{}`", node.bus)))?;
475 let terminal_position = bus
476 .terminals
477 .iter()
478 .position(|terminal| terminal == &node.terminal)
479 .ok_or_else(|| {
480 invalid(format!(
481 "topology names unknown terminal `{}/{}`",
482 node.bus, node.terminal
483 ))
484 })?;
485 let voltage_bounds_selected = instance
486 .base_instance()
487 .constraints()
488 .terminal_voltage_bounds
489 .selects(&bus.id);
490 let (squared_voltage_min, squared_voltage_max) = if voltage_bounds_selected {
491 terminal_bounds(bus, terminal_position)?
492 } else {
493 (None, None)
494 };
495 if squared_voltage_min
496 .zip(squared_voltage_max)
497 .is_some_and(|(lower, upper)| lower > upper)
498 {
499 return Err(invalid(format!(
500 "bus `{}` terminal `{}` has an inverted voltage interval",
501 node.bus, node.terminal
502 )));
503 }
504 let fixed_squared_voltage = source_nodes
505 .contains(&(node.bus.to_ascii_lowercase(), node.terminal.clone()))
506 .then_some(reference.magnitude * reference.magnitude);
507 nodes.push(LinDist3FlowNodeData {
508 node: node.clone(),
509 reference_magnitude: reference.magnitude,
510 reference_angle: reference.angle,
511 squared_voltage_min,
512 squared_voltage_max,
513 fixed_squared_voltage,
514 });
515 }
516
517 let mut grouped = BTreeMap::<usize, Vec<_>>::new();
518 for conductor in &instance.topology().conductors {
519 grouped
520 .entry(conductor.source_line_row)
521 .or_default()
522 .push(conductor);
523 }
524 let mut lines = Vec::with_capacity(grouped.len());
525 for (line_row, mut conductors) in grouped {
526 let line = network
527 .lines()
528 .get(line_row)
529 .ok_or_else(|| invalid(format!("topology names unknown line row {line_row}")))?;
530 conductors.sort_by_key(|conductor| conductor.conductor_position);
531 if conductors.len() != line.terminal_map_from.len()
532 || conductors
533 .iter()
534 .enumerate()
535 .any(|(position, conductor)| conductor.conductor_position != position)
536 {
537 return Err(invalid(format!(
538 "line `{}` topology does not contain one row per conductor",
539 line.name
540 )));
541 }
542 let reversed = conductors[0].reversed;
543 if conductors
544 .iter()
545 .any(|conductor| conductor.reversed != reversed)
546 {
547 return Err(invalid(format!(
548 "line `{}` has inconsistent conductor orientation",
549 line.name
550 )));
551 }
552 let parent_nodes = conductors
553 .iter()
554 .map(|conductor| {
555 node_positions
556 .get(&(
557 conductor.parent.bus.to_ascii_lowercase(),
558 conductor.parent.terminal.clone(),
559 ))
560 .copied()
561 .ok_or_else(|| {
562 invalid(format!(
563 "line `{}` parent terminal is absent from the topology",
564 line.name
565 ))
566 })
567 })
568 .collect::<Result<Vec<_>>>()?;
569 let child_nodes = conductors
570 .iter()
571 .map(|conductor| {
572 node_positions
573 .get(&(
574 conductor.child.bus.to_ascii_lowercase(),
575 conductor.child.terminal.clone(),
576 ))
577 .copied()
578 .ok_or_else(|| {
579 invalid(format!(
580 "line `{}` child terminal is absent from the topology",
581 line.name
582 ))
583 })
584 })
585 .collect::<Result<Vec<_>>>()?;
586 let code = network.linecode(&line.linecode).ok_or_else(|| {
587 invalid(format!(
588 "line `{}` names missing linecode `{}`",
589 line.name, line.linecode
590 ))
591 })?;
592 let n = conductors.len();
593 if code.r_series.len() != n || code.x_series.len() != n {
594 return Err(invalid(format!(
595 "linecode `{}` does not have {n} series rows",
596 code.name
597 )));
598 }
599 let mut impedance = Vec::with_capacity(n);
600 for row in 0..n {
601 if code.r_series[row].len() != n || code.x_series[row].len() != n {
602 return Err(invalid(format!(
603 "linecode `{}` series row {row} does not have {n} entries",
604 code.name
605 )));
606 }
607 impedance.push(
608 (0..n)
609 .map(|column| {
610 Complex64::new(
611 code.r_series[row][column] * line.length,
612 code.x_series[row][column] * line.length,
613 )
614 })
615 .collect(),
616 );
617 }
618 let reference_from = parent_nodes
619 .iter()
620 .map(|&node| {
621 Complex64::from_polar(nodes[node].reference_magnitude, nodes[node].reference_angle)
622 })
623 .collect::<Vec<_>>();
624 let drop = line_drop_coefficients(&impedance, &reference_from)?;
625 let limits_selected = instance
626 .base_instance()
627 .constraints()
628 .conductor_limits
629 .selects(&line.name);
630 let current_limit = if limits_selected {
631 optional_ratings(
632 line.i_max.as_deref().or(code.i_max.as_deref()),
633 n,
634 "line current limit",
635 )?
636 } else {
637 vec![None; n]
638 };
639 let apparent_power_limit = if limits_selected {
640 optional_ratings(
641 line.s_max.as_deref().or(code.s_max.as_deref()),
642 n,
643 "line apparent-power limit",
644 )?
645 } else {
646 vec![None; n]
647 };
648 lines.push(LinDist3FlowLineData {
649 line: line.name.clone(),
650 source_line_row: line_row,
651 parent_nodes,
652 child_nodes,
653 reversed,
654 drop,
655 current_limit,
656 apparent_power_limit,
657 });
658 }
659 Ok(LinDist3FlowNetworkData { nodes, lines })
660}
661
662#[cfg(test)]
663mod tests {
664 use std::f64::consts::PI;
665
666 use approx::assert_relative_eq;
667 use powerio_dist::{DistBus, DistLine, DistLineCode, MulticonductorNetwork, VoltageSource};
668 use powerio_prob::{LinDist3FlowBuildOptions, LinDist3FlowOpfInstance};
669
670 use super::*;
671
672 #[test]
673 fn cross_voltage_closure_is_exact_at_its_reference() {
674 let left = Complex64::from_polar(230.0, 0.13);
675 let right = Complex64::from_polar(221.0, -2.01);
676 let coefficients = cross_voltage_coefficients(left, right).unwrap();
677 let value = evaluate_cross_voltage(&coefficients, left.norm_sqr(), right.norm_sqr());
678
679 assert_relative_eq!(value.re, (left * right.conj()).re, epsilon = 1e-10);
680 assert_relative_eq!(value.im, (left * right.conj()).im, epsilon = 1e-10);
681 }
682
683 #[test]
684 fn winding_closure_reproduces_a_line_to_line_voltage() {
685 let reference = [
686 Complex64::from_polar(230.0, 0.0),
687 Complex64::from_polar(230.0, -2.0 * PI / 3.0),
688 ];
689 let coefficients = winding_voltage_coefficients(&[1.0, -1.0], &reference).unwrap();
690 let value = evaluate_affine(
691 &coefficients,
692 &[reference[0].norm_sqr(), reference[1].norm_sqr()],
693 )
694 .unwrap();
695
696 assert_relative_eq!(
697 value,
698 (reference[0] - reference[1]).norm_sqr(),
699 epsilon = 1e-9
700 );
701 }
702
703 #[test]
704 fn identity_connection_maps_each_channel_to_its_terminal() {
705 let reference = [
706 Complex64::from_polar(1.0, 0.0),
707 Complex64::from_polar(1.0, -2.0 * PI / 3.0),
708 ];
709 let map = connection_power_map(&[vec![1.0, 0.0], vec![0.0, 1.0]], &reference).unwrap();
710
711 assert_relative_eq!(map.matrix[0][0].re, 1.0, epsilon = 1e-12);
712 assert_relative_eq!(map.matrix[1][1].re, 1.0, epsilon = 1e-12);
713 assert_relative_eq!(map.matrix[0][1].norm(), 0.0, epsilon = 1e-12);
714 assert_relative_eq!(map.matrix[1][0].norm(), 0.0, epsilon = 1e-12);
715 }
716
717 #[test]
718 fn line_drop_uses_the_reference_phase_ratios() {
719 let z = vec![
720 vec![Complex64::new(0.1, 0.2), Complex64::new(0.03, 0.04)],
721 vec![Complex64::new(0.03, 0.04), Complex64::new(0.1, 0.2)],
722 ];
723 let reference = [
724 Complex64::from_polar(1.0, 0.0),
725 Complex64::from_polar(1.0, -2.0 * PI / 3.0),
726 ];
727 let coefficients = line_drop_coefficients(&z, &reference).unwrap();
728
729 assert_relative_eq!(coefficients.active[0][0], 0.2, epsilon = 1e-12);
730 assert_relative_eq!(coefficients.reactive[0][0], 0.4, epsilon = 1e-12);
731 let expected = z[0][1].conj() * reference[0] / reference[1];
732 assert_relative_eq!(
733 coefficients.active[0][1],
734 2.0 * expected.re,
735 epsilon = 1e-12
736 );
737 assert_relative_eq!(
738 coefficients.reactive[0][1],
739 -2.0 * expected.im,
740 epsilon = 1e-12
741 );
742 }
743
744 #[test]
745 fn invalid_coefficient_operands_are_refused() {
746 assert!(
747 cross_voltage_coefficients(Complex64::new(0.0, 0.0), Complex64::new(1.0, 0.0)).is_err()
748 );
749 assert!(connection_power_map(&[vec![1.0, -1.0]], &[Complex64::new(1.0, 0.0)]).is_err());
750 assert!(
751 line_drop_coefficients(
752 &[vec![Complex64::new(1.0, 0.0)]],
753 &[Complex64::new(1.0, 0.0), Complex64::new(1.0, 0.0),]
754 )
755 .is_err()
756 );
757 }
758
759 #[test]
760 fn network_data_keeps_si_scaling_orientation_bounds_and_ratings() {
761 let terminals = vec!["1".to_owned(), "2".to_owned()];
762 let mut network = MulticonductorNetwork::named("line-data");
763 let mut source_bus = DistBus::new("source", terminals.clone());
764 source_bus.v_min = Some(220.0);
765 source_bus.v_max = Some(240.0);
766 network.buses_mut().push(source_bus);
767 network
768 .buses_mut()
769 .push(DistBus::new("load", terminals.clone()));
770 let mut code = DistLineCode::new(
771 "two-phase",
772 vec![vec![0.1, 0.0], vec![0.0, 0.1]],
773 vec![vec![0.2, 0.0], vec![0.0, 0.2]],
774 );
775 code.i_max = Some(vec![100.0, 101.0]);
776 code.s_max = Some(vec![10_000.0, 11_000.0]);
777 network.line_codes_mut().push(code);
778 let mut line = DistLine::new(
779 "line",
780 "load",
781 "source",
782 terminals.clone(),
783 terminals.clone(),
784 "two-phase",
785 10.0,
786 );
787 line.i_max = Some(vec![90.0, 91.0]);
788 network.lines_mut().push(line);
789 network.sources_mut().push(VoltageSource::new(
790 "grid",
791 "source",
792 terminals,
793 vec![230.0, 230.0],
794 vec![0.0, -2.0 * PI / 3.0],
795 ));
796 let instance =
797 LinDist3FlowOpfInstance::from_network(network, LinDist3FlowBuildOptions::default())
798 .unwrap();
799
800 let data = build_lindist3flow_network_data(&instance).unwrap();
801
802 assert_eq!(data.nodes.len(), 4);
803 assert_relative_eq!(
804 data.nodes[0].fixed_squared_voltage.unwrap(),
805 230.0_f64.powi(2),
806 epsilon = 1e-12
807 );
808 assert_relative_eq!(
809 data.nodes[0].squared_voltage_min.unwrap(),
810 220.0_f64.powi(2),
811 epsilon = 1e-12
812 );
813 assert!(data.nodes[2].fixed_squared_voltage.is_none());
814 assert_eq!(data.lines.len(), 1);
815 let line = &data.lines[0];
816 assert!(line.reversed);
817 assert_eq!(line.parent_nodes, [0, 1]);
818 assert_eq!(line.child_nodes, [2, 3]);
819 assert_eq!(line.current_limit, [Some(90.0), Some(91.0)]);
820 assert_eq!(line.apparent_power_limit, [Some(10_000.0), Some(11_000.0)]);
821 assert_relative_eq!(line.drop.active[0][0], 2.0, epsilon = 1e-12);
822 assert_relative_eq!(line.drop.reactive[0][0], 4.0, epsilon = 1e-12);
823 }
824}