Skip to main content

powerio_matrix/
lindist3flow.rs

1//! Solver-neutral affine coefficient oracles for LinDist3Flow.
2//!
3//! These functions expose the small dense blocks used when a solver adapter
4//! assembles the formulation. They do not own variables, sparse row numbers,
5//! or a solver model.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use num_complex::Complex64;
10use powerio_prob::{LinDist3FlowNode, LinDist3FlowOpfInstance};
11
12use crate::{Error, Result};
13
14/// Affine closure `c + a w_phi + b w_psi` for one cross-voltage product.
15#[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/// Real affine scalar `constant + coefficients' * w`.
24#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct AffineScalarCoefficients {
27    pub constant: f64,
28    pub coefficients: Vec<f64>,
29}
30
31/// Reference-frozen map from physical channel powers to terminal powers.
32#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct ConnectionPowerMap {
35    /// Complex terminal-by-channel map.
36    pub matrix: Vec<Vec<Complex64>>,
37    pub real_part: Vec<Vec<f64>>,
38    pub imag_part: Vec<Vec<f64>>,
39    /// Reference voltage across each physical channel.
40    pub reference_winding_voltage: Vec<Complex64>,
41}
42
43/// Real coefficient blocks in `w_child = w_parent - M p - N q`.
44#[derive(Clone, Debug, PartialEq)]
45#[non_exhaustive]
46pub struct LineDropCoefficients {
47    /// `M = 2 Re(conj(Z) .* Gamma)`.
48    pub active: Vec<Vec<f64>>,
49    /// `N = -2 Im(conj(Z) .* Gamma)`.
50    pub reactive: Vec<Vec<f64>>,
51}
52
53/// One squared-voltage variable row in the solver-neutral network data.
54#[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    /// Fixed squared voltage for a voltage-source terminal.
63    pub fixed_squared_voltage: Option<f64>,
64}
65
66/// One coupled line block in the solver-neutral network data.
67#[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    /// Per-conductor amperes; `None` means unbounded.
77    pub current_limit: Vec<Option<f64>>,
78    /// Per-conductor VA; `None` means unbounded.
79    pub apparent_power_limit: Vec<Option<f64>>,
80}
81
82/// Numerical rows for the voltage, line-drop, and line SOC portion of L3F.
83///
84/// Device connection maps and nodal injection rows remain separate compiler
85/// stages. Keeping this boundary explicit lets a Tellegen adapter consume the
86/// line physics now without treating an incomplete solver model as complete.
87#[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
211/// Form the fixed-angle first-order closure of `v_phi * conj(v_psi)`.
212///
213/// At the reference point it reproduces the product exactly. Away from that
214/// point it is the first-order Taylor expansion of
215/// `sqrt(w_phi w_psi) exp(j delta_theta)` with the angle difference fixed.
216///
217/// # Errors
218/// Either reference phasor is zero or non-finite.
219pub 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/// Evaluate a cross-voltage affine closure at two squared magnitudes.
241#[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
252/// Form real affine coefficients for the squared winding voltage `|d v|^2`.
253///
254/// # Errors
255/// The incidence row and reference have different or zero length, contain a
256/// non-finite value, or a reference phasor is zero.
257pub 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
292/// Evaluate a real affine scalar.
293///
294/// # Errors
295/// The coefficient and variable vectors differ in length.
296pub 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
313/// Construct `H = diag(vbar) D' diag(D vbar)^-1`.
314///
315/// A channel power `s_channel` maps to terminal powers as
316/// `s_terminal = H s_channel`; the split is frozen at the reference phasors.
317///
318/// # Errors
319/// The incidence matrix is empty, ragged, non-finite, has the wrong terminal
320/// arity, or produces a zero reference winding voltage.
321pub 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
375/// Construct the line voltage-drop blocks for a complex series impedance.
376///
377/// `Gamma[phi, psi] = vbar_phi / vbar_psi`,
378/// `M = 2 Re(conj(Z) .* Gamma)`, and
379/// `N = -2 Im(conj(Z) .* Gamma)`.
380///
381/// # Errors
382/// `Z` is not finite and square with the reference arity, or a reference
383/// phasor is zero or non-finite.
384pub 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/// Prepare the voltage, coupled line-drop, and line-limit rows of an L3F
423/// instance in SI units.
424///
425/// The line relation is `w_child = w_parent - M p - N q`. A solver adapter can
426/// impose each stated current limit as the native rotated cone
427/// `p² + q² <= w I_max²` at both endpoints, and each apparent-power limit as
428/// `p² + q² <= S_max²`.
429///
430/// # Errors
431/// A topology identity cannot be resolved, a line block does not align with
432/// its terminal maps or linecode, or a voltage/limit/coefficient is invalid.
433#[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}