Skip to main content

powerio_matrix/
lindist3flow_standard.rs

1//! Sparse conic standard form for solver adapters.
2//!
3//! This is the final PowerIO-owned numerical boundary. It follows
4//! `min 1/2 x' P x + q' x` subject to `A x + s = b`, `s in K`, which is the
5//! convention consumed by Clarabel. `P` is currently zero because the BMOPF
6//! dispatch objective is linear.
7
8use powerio_prob::LinDist3FlowOpfInstance;
9
10use crate::matrix::triplet::CooBuilder;
11use crate::{
12    Error, LinDist3FlowCone, LinDist3FlowConeOrigin, LinDist3FlowConicProblem,
13    LinDist3FlowDecisionVariable, LinDist3FlowEqualityOrigin, LinDist3FlowLinearExpression, Result,
14    SparseMatrix, build_lindist3flow_conic_problem, lindist3flow_values_from_primal,
15};
16
17/// Numerical coordinate choices for the sparse solver program.
18#[derive(Clone, Copy, Debug, PartialEq)]
19#[non_exhaustive]
20pub struct LinDist3FlowStandardFormOptions {
21    /// Use per-unit decision variables and scaled constraint rows.
22    pub per_unit: bool,
23    /// System apparent-power base in VA.
24    pub apparent_power_base: f64,
25}
26
27impl LinDist3FlowStandardFormOptions {
28    #[must_use]
29    pub const fn si() -> Self {
30        Self {
31            per_unit: false,
32            apparent_power_base: 1_000_000.0,
33        }
34    }
35
36    #[must_use]
37    pub const fn per_unit(apparent_power_base: f64) -> Self {
38        Self {
39            per_unit: true,
40            apparent_power_base,
41        }
42    }
43}
44
45impl Default for LinDist3FlowStandardFormOptions {
46    fn default() -> Self {
47        Self::per_unit(1_000_000.0)
48    }
49}
50
51/// Diagonal coordinate maps applied to the canonical SI program.
52///
53/// Physical primals satisfy `x_si = variable_scale .* x_solver`. Scaled rows
54/// satisfy `(A_solver, b_solver) = row_scale .* (A_si * variable_scale, b_si)`.
55#[derive(Clone, Debug, PartialEq)]
56#[non_exhaustive]
57pub struct LinDist3FlowScaling {
58    /// Apparent-power base in VA, or `None` when solver coordinates are SI.
59    pub apparent_power_base: Option<f64>,
60    /// One positive SI-unit multiplier per decision variable.
61    pub variable_scale: Vec<f64>,
62    /// One positive multiplier per standard-form constraint row.
63    pub row_scale: Vec<f64>,
64}
65
66/// One contiguous cone block in standard-form row order.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum LinDist3FlowStandardCone {
70    Zero { dimension: usize },
71    Nonnegative { dimension: usize },
72    SecondOrder { dimension: usize },
73}
74
75impl LinDist3FlowStandardCone {
76    #[must_use]
77    pub const fn dimension(self) -> usize {
78        match self {
79            Self::Zero { dimension }
80            | Self::Nonnegative { dimension }
81            | Self::SecondOrder { dimension } => dimension,
82        }
83    }
84}
85
86/// Semantic provenance for one row of `A` and `b`.
87#[derive(Clone, Debug, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum LinDist3FlowStandardRowOrigin {
90    Equality(LinDist3FlowEqualityOrigin),
91    VariableLowerBound {
92        column: usize,
93    },
94    VariableUpperBound {
95        column: usize,
96    },
97    Cone {
98        origin: LinDist3FlowConeOrigin,
99        /// Position after any rotated-to-standard transformation.
100        row_in_cone: usize,
101    },
102}
103
104/// Clarabel-compatible sparse conic data plus its semantic source model.
105#[derive(Clone, Debug)]
106#[non_exhaustive]
107pub struct LinDist3FlowStandardForm {
108    /// Zero quadratic objective matrix, CSC, `n x n`.
109    pub p: SparseMatrix,
110    /// Linear objective vector, length `n`.
111    pub q: Vec<f64>,
112    /// Constraint matrix in CSC storage.
113    pub a: SparseMatrix,
114    /// Constraint right-hand side, length `m`.
115    pub b: Vec<f64>,
116    /// Contiguous cone blocks whose dimensions sum to `m`.
117    pub cones: Vec<LinDist3FlowStandardCone>,
118    /// One semantic origin per constraint row.
119    pub row_origins: Vec<LinDist3FlowStandardRowOrigin>,
120    /// Exact diagonal maps between solver coordinates and canonical SI.
121    pub scaling: LinDist3FlowScaling,
122    /// Canonical model retained for primal decoding and identity lookup.
123    pub canonical: LinDist3FlowConicProblem,
124}
125
126fn invalid(reason: impl Into<String>) -> Error {
127    Error::InvalidLinDist3FlowCoefficients {
128        reason: reason.into(),
129    }
130}
131
132fn bound_count(problem: &LinDist3FlowConicProblem) -> usize {
133    problem
134        .variables
135        .iter()
136        .map(|variable| {
137            usize::from(variable.lower.is_some()) + usize::from(variable.upper.is_some())
138        })
139        .sum()
140}
141
142fn cone_dimension(cone: &LinDist3FlowCone) -> usize {
143    match cone {
144        LinDist3FlowCone::SecondOrder { arguments, .. }
145        | LinDist3FlowCone::RotatedSecondOrder { arguments, .. } => arguments.len(),
146    }
147}
148
149fn add_slack_expression(
150    a: &mut CooBuilder,
151    b: &mut Vec<f64>,
152    row: usize,
153    parts: &[(&LinDist3FlowLinearExpression, f64)],
154) -> Result<()> {
155    let mut constant = 0.0;
156    for (expression, scale) in parts {
157        constant += scale * expression.constant;
158        for term in &expression.terms {
159            let coefficient = -scale * term.coefficient;
160            if !coefficient.is_finite() {
161                return Err(invalid(format!(
162                    "standard-form row {row} has a non-finite matrix coefficient"
163                )));
164            }
165            a.add(row, term.column, coefficient);
166        }
167    }
168    if !constant.is_finite() {
169        return Err(invalid(format!(
170            "standard-form row {row} has a non-finite right-hand side"
171        )));
172    }
173    b.push(constant);
174    Ok(())
175}
176
177fn push_cone_origins(
178    origins: &mut Vec<LinDist3FlowStandardRowOrigin>,
179    origin: &LinDist3FlowConeOrigin,
180    dimension: usize,
181) {
182    origins.extend(
183        (0..dimension).map(|row_in_cone| LinDist3FlowStandardRowOrigin::Cone {
184            origin: origin.clone(),
185            row_in_cone,
186        }),
187    );
188}
189
190/// Compile a LinDist3Flow instance into sparse `P, q, A, b, K` standard form.
191///
192/// Rotated cones `(u, v, z...)` are mapped to the ordinary SOC
193/// `(u + v, u - v, sqrt(2) z...)`. This preserves
194/// `2 u v >= ||z||²` and means an adapter needs only zero, nonnegative, and
195/// ordinary second-order cones.
196///
197/// # Errors
198/// As [`build_lindist3flow_conic_problem`], or a cone has an invalid
199/// dimension or produces non-finite standard-form data.
200#[allow(clippy::many_single_char_names, clippy::too_many_lines)]
201fn build_lindist3flow_standard_form_si(
202    instance: &LinDist3FlowOpfInstance,
203) -> Result<LinDist3FlowStandardForm> {
204    let canonical = build_lindist3flow_conic_problem(instance)?;
205    let n = canonical.variables.len();
206    let bounds = bound_count(&canonical);
207    let cone_rows: usize = canonical.cones.iter().map(cone_dimension).sum();
208    let m = canonical
209        .equalities
210        .len()
211        .checked_add(bounds)
212        .and_then(|rows| rows.checked_add(cone_rows))
213        .ok_or_else(|| invalid("LinDist3Flow standard-form row count overflows usize"))?;
214    let estimated_nnz = canonical
215        .equalities
216        .iter()
217        .map(|row| row.expression.terms.len())
218        .sum::<usize>()
219        .saturating_add(bounds)
220        .saturating_add(
221            canonical
222                .cones
223                .iter()
224                .map(|cone| match cone {
225                    LinDist3FlowCone::SecondOrder { arguments, .. }
226                    | LinDist3FlowCone::RotatedSecondOrder { arguments, .. } => arguments
227                        .iter()
228                        .map(|argument| argument.terms.len())
229                        .sum::<usize>(),
230                })
231                .sum::<usize>(),
232        );
233    let p = CooBuilder::new(n).finish_csc();
234    let q = canonical
235        .variables
236        .iter()
237        .map(|variable| variable.objective_coefficient)
238        .collect();
239    let mut a = CooBuilder::with_capacity_rect(m, n, estimated_nnz);
240    let mut b = Vec::with_capacity(m);
241    let mut cones = Vec::new();
242    let mut row_origins = Vec::with_capacity(m);
243    let mut row = 0;
244
245    if !canonical.equalities.is_empty() {
246        cones.push(LinDist3FlowStandardCone::Zero {
247            dimension: canonical.equalities.len(),
248        });
249    }
250    for equality in &canonical.equalities {
251        for term in &equality.expression.terms {
252            a.add(row, term.column, term.coefficient);
253        }
254        b.push(-equality.expression.constant);
255        row_origins.push(LinDist3FlowStandardRowOrigin::Equality(
256            equality.origin.clone(),
257        ));
258        row += 1;
259    }
260
261    if bounds != 0 {
262        cones.push(LinDist3FlowStandardCone::Nonnegative { dimension: bounds });
263    }
264    for (column, variable) in canonical.variables.iter().enumerate() {
265        if let Some(lower) = variable.lower {
266            // -x + s = -lower, so s = x - lower >= 0.
267            a.add(row, column, -1.0);
268            b.push(-lower);
269            row_origins.push(LinDist3FlowStandardRowOrigin::VariableLowerBound { column });
270            row += 1;
271        }
272        if let Some(upper) = variable.upper {
273            // x + s = upper, so s = upper - x >= 0.
274            a.add(row, column, 1.0);
275            b.push(upper);
276            row_origins.push(LinDist3FlowStandardRowOrigin::VariableUpperBound { column });
277            row += 1;
278        }
279    }
280
281    for cone in &canonical.cones {
282        match cone {
283            LinDist3FlowCone::SecondOrder { origin, arguments } => {
284                if arguments.len() < 2 {
285                    return Err(invalid("a second-order cone needs at least two rows"));
286                }
287                cones.push(LinDist3FlowStandardCone::SecondOrder {
288                    dimension: arguments.len(),
289                });
290                push_cone_origins(&mut row_origins, origin, arguments.len());
291                for argument in arguments {
292                    add_slack_expression(&mut a, &mut b, row, &[(argument, 1.0)])?;
293                    row += 1;
294                }
295            }
296            LinDist3FlowCone::RotatedSecondOrder { origin, arguments } => {
297                if arguments.len() < 3 {
298                    return Err(invalid(
299                        "a rotated second-order cone needs at least three rows",
300                    ));
301                }
302                cones.push(LinDist3FlowStandardCone::SecondOrder {
303                    dimension: arguments.len(),
304                });
305                push_cone_origins(&mut row_origins, origin, arguments.len());
306                add_slack_expression(
307                    &mut a,
308                    &mut b,
309                    row,
310                    &[(&arguments[0], 1.0), (&arguments[1], 1.0)],
311                )?;
312                row += 1;
313                add_slack_expression(
314                    &mut a,
315                    &mut b,
316                    row,
317                    &[(&arguments[0], 1.0), (&arguments[1], -1.0)],
318                )?;
319                row += 1;
320                for argument in &arguments[2..] {
321                    add_slack_expression(
322                        &mut a,
323                        &mut b,
324                        row,
325                        &[(argument, std::f64::consts::SQRT_2)],
326                    )?;
327                    row += 1;
328                }
329            }
330        }
331    }
332    debug_assert_eq!(row, m);
333    debug_assert_eq!(b.len(), m);
334    debug_assert_eq!(row_origins.len(), m);
335    debug_assert_eq!(cones.iter().map(|cone| cone.dimension()).sum::<usize>(), m);
336    Ok(LinDist3FlowStandardForm {
337        p,
338        q,
339        a: a.finish_csc(),
340        b,
341        cones,
342        row_origins,
343        scaling: LinDist3FlowScaling {
344            apparent_power_base: None,
345            variable_scale: vec![1.0; n],
346            row_scale: vec![1.0; m],
347        },
348        canonical,
349    })
350}
351
352fn per_unit_scales(
353    form: &LinDist3FlowStandardForm,
354    power_base: f64,
355) -> Result<LinDist3FlowScaling> {
356    if !power_base.is_finite() || power_base <= 0.0 {
357        return Err(invalid(
358            "LinDist3Flow apparent-power base must be finite and positive",
359        ));
360    }
361    let variable_scale = form
362        .canonical
363        .variables
364        .iter()
365        .map(|variable| match &variable.variable {
366            LinDist3FlowDecisionVariable::SquaredVoltage { node } => {
367                form.canonical.preparation.network.nodes[*node]
368                    .reference_magnitude
369                    .powi(2)
370            }
371            LinDist3FlowDecisionVariable::Power(_) => power_base,
372        })
373        .collect::<Vec<_>>();
374    if variable_scale
375        .iter()
376        .any(|scale| !scale.is_finite() || *scale <= 0.0)
377    {
378        return Err(invalid(
379            "LinDist3Flow variable scaling contains a non-finite or nonpositive base",
380        ));
381    }
382    let row_scale = form
383        .row_origins
384        .iter()
385        .map(|origin| match origin {
386            LinDist3FlowStandardRowOrigin::Equality(LinDist3FlowEqualityOrigin::LineDrop {
387                line,
388                conductor,
389            }) => {
390                let node = form.canonical.preparation.network.lines[*line].child_nodes[*conductor];
391                1.0 / form.canonical.preparation.network.nodes[node]
392                    .reference_magnitude
393                    .powi(2)
394            }
395            LinDist3FlowStandardRowOrigin::Equality(
396                LinDist3FlowEqualityOrigin::ActiveBalance { .. }
397                | LinDist3FlowEqualityOrigin::ReactiveBalance { .. },
398            )
399            | LinDist3FlowStandardRowOrigin::Cone { .. } => 1.0 / power_base,
400            LinDist3FlowStandardRowOrigin::VariableLowerBound { column }
401            | LinDist3FlowStandardRowOrigin::VariableUpperBound { column } => {
402                1.0 / variable_scale[*column]
403            }
404        })
405        .collect::<Vec<_>>();
406    Ok(LinDist3FlowScaling {
407        apparent_power_base: Some(power_base),
408        variable_scale,
409        row_scale,
410    })
411}
412
413fn apply_scaling(form: &mut LinDist3FlowStandardForm, scaling: LinDist3FlowScaling) {
414    for (column, mut entries) in form.a.outer_iterator_mut().enumerate() {
415        for (row, value) in entries.iter_mut() {
416            *value *= scaling.variable_scale[column] * scaling.row_scale[row];
417        }
418    }
419    for (coefficient, scale) in form.q.iter_mut().zip(&scaling.variable_scale) {
420        *coefficient *= scale;
421    }
422    for (right_hand_side, scale) in form.b.iter_mut().zip(&scaling.row_scale) {
423        *right_hand_side *= scale;
424    }
425    form.scaling = scaling;
426}
427
428/// Compile a LinDist3Flow instance in the default per-unit coordinates using
429/// a 1 MVA system power base. Input, canonical data, and decoded results remain
430/// SI.
431///
432/// # Errors
433/// As [`build_lindist3flow_standard_form_with_options`].
434pub fn build_lindist3flow_standard_form(
435    instance: &LinDist3FlowOpfInstance,
436) -> Result<LinDist3FlowStandardForm> {
437    build_lindist3flow_standard_form_with_options(
438        instance,
439        LinDist3FlowStandardFormOptions::default(),
440    )
441}
442
443/// Compile sparse standard form using explicit SI or per-unit solver
444/// coordinates.
445///
446/// # Errors
447/// As [`build_lindist3flow_conic_problem`], or the requested power base is
448/// non-finite or nonpositive.
449pub fn build_lindist3flow_standard_form_with_options(
450    instance: &LinDist3FlowOpfInstance,
451    options: LinDist3FlowStandardFormOptions,
452) -> Result<LinDist3FlowStandardForm> {
453    let mut form = build_lindist3flow_standard_form_si(instance)?;
454    if options.per_unit {
455        let scaling = per_unit_scales(&form, options.apparent_power_base)?;
456        apply_scaling(&mut form, scaling);
457    }
458    Ok(form)
459}
460
461/// Decode a solver-coordinate primal vector into physical SI values.
462///
463/// # Errors
464/// The primal vector has the wrong length or contains an inconsistent
465/// canonical semantic index.
466pub fn lindist3flow_values_from_standard_primal(
467    form: &LinDist3FlowStandardForm,
468    primal: &[f64],
469) -> Result<powerio_prob::LinDist3FlowOpfValues> {
470    if primal.len() != form.scaling.variable_scale.len() {
471        return Err(invalid(format!(
472            "LinDist3Flow solver primal has length {}, expected {}",
473            primal.len(),
474            form.scaling.variable_scale.len()
475        )));
476    }
477    let physical = primal
478        .iter()
479        .zip(&form.scaling.variable_scale)
480        .map(|(value, scale)| value * scale)
481        .collect::<Vec<_>>();
482    lindist3flow_values_from_primal(&form.canonical, &physical)
483}
484
485#[cfg(test)]
486mod tests {
487    use approx::assert_relative_eq;
488    use powerio_dist::{
489        Configuration, DistBus, DistGenerator, DistLine, DistLineCode, MulticonductorNetwork,
490        VoltageSource,
491    };
492    use powerio_prob::{LinDist3FlowBuildOptions, LinDist3FlowOpfInstance};
493
494    use super::*;
495
496    fn instance() -> LinDist3FlowOpfInstance {
497        let terminal = vec!["1".to_owned()];
498        let mut network = MulticonductorNetwork::named("standard");
499        let mut source_bus = DistBus::new("source", terminal.clone());
500        source_bus.v_min = Some(220.0);
501        source_bus.v_max = Some(240.0);
502        network.buses_mut().push(source_bus);
503        let mut load_bus = DistBus::new("load", terminal.clone());
504        load_bus.v_min = Some(210.0);
505        load_bus.v_max = Some(240.0);
506        network.buses_mut().push(load_bus);
507        let mut code = DistLineCode::new("one", vec![vec![0.1]], vec![vec![0.2]]);
508        code.i_max = Some(vec![10.0]);
509        code.s_max = Some(vec![2_000.0]);
510        network.line_codes_mut().push(code);
511        network.lines_mut().push(DistLine::new(
512            "line",
513            "source",
514            "load",
515            terminal.clone(),
516            terminal.clone(),
517            "one",
518            1.0,
519        ));
520        let mut source =
521            VoltageSource::new("grid", "source", terminal.clone(), vec![230.0], vec![0.0]);
522        source.energy_cost_rate = Some(vec![0.3]);
523        network.sources_mut().push(source);
524        let mut generator = DistGenerator::new(
525            "pv",
526            "load",
527            terminal,
528            Configuration::Wye,
529            vec![500.0],
530            vec![0.0],
531        );
532        generator.p_min = Some(vec![0.0]);
533        generator.p_max = Some(vec![1_000.0]);
534        generator.q_min = Some(vec![-500.0]);
535        generator.q_max = Some(vec![500.0]);
536        generator.s_max = Some(vec![1_100.0]);
537        generator.i_max = Some(vec![5.0]);
538        generator.cost = Some(vec![0.1]);
539        network.generators_mut().push(generator);
540        LinDist3FlowOpfInstance::from_network(network, LinDist3FlowBuildOptions::default()).unwrap()
541    }
542
543    fn entry(matrix: &SparseMatrix, row: usize, column: usize) -> f64 {
544        matrix.get(row, column).copied().unwrap_or_default()
545    }
546
547    fn si_form() -> LinDist3FlowStandardForm {
548        build_lindist3flow_standard_form_with_options(
549            &instance(),
550            LinDist3FlowStandardFormOptions::si(),
551        )
552        .unwrap()
553    }
554
555    #[test]
556    fn sparse_shapes_and_cone_blocks_match_clarabel_standard_form() {
557        let form = si_form();
558        assert_eq!(form.p.shape(), (8, 8));
559        assert!(form.p.is_csc());
560        assert_eq!(form.p.nnz(), 0);
561        assert_eq!(form.a.shape(), (31, 8));
562        assert!(form.a.is_csc());
563        assert_eq!(form.q.len(), 8);
564        assert_eq!(form.b.len(), 31);
565        assert_eq!(form.row_origins.len(), 31);
566        assert_eq!(
567            form.cones,
568            vec![
569                LinDist3FlowStandardCone::Zero { dimension: 5 },
570                LinDist3FlowStandardCone::Nonnegative { dimension: 8 },
571                LinDist3FlowStandardCone::SecondOrder { dimension: 3 },
572                LinDist3FlowStandardCone::SecondOrder { dimension: 4 },
573                LinDist3FlowStandardCone::SecondOrder { dimension: 4 },
574                LinDist3FlowStandardCone::SecondOrder { dimension: 3 },
575                LinDist3FlowStandardCone::SecondOrder { dimension: 4 },
576            ]
577        );
578    }
579
580    #[test]
581    fn equality_and_bound_rows_have_ax_plus_s_equals_b_signs() {
582        let form = si_form();
583        assert_relative_eq!(entry(&form.a, 0, 0), -1.0);
584        assert_relative_eq!(entry(&form.a, 0, 1), 1.0);
585        assert_relative_eq!(entry(&form.a, 0, 2), 0.2);
586        assert_relative_eq!(entry(&form.a, 0, 3), 0.4);
587        assert_relative_eq!(form.b[0], 0.0);
588
589        assert_relative_eq!(entry(&form.a, 5, 0), -1.0);
590        assert_relative_eq!(form.b[5], -230.0f64.powi(2));
591        assert_relative_eq!(entry(&form.a, 6, 0), 1.0);
592        assert_relative_eq!(form.b[6], 230.0f64.powi(2));
593    }
594
595    #[test]
596    fn rotated_current_cone_is_an_equivalent_ordinary_soc() {
597        let form = si_form();
598        // Five equalities, eight bounds, then the 3-row line apparent-power
599        // cone. The first line-current SOC therefore starts at row 16.
600        let row = 16;
601        assert_relative_eq!(entry(&form.a, row, 0), -10.0 / 230.0);
602        assert_relative_eq!(form.b[row], 1_150.0);
603        assert_relative_eq!(entry(&form.a, row + 1, 0), -10.0 / 230.0);
604        assert_relative_eq!(form.b[row + 1], -1_150.0);
605        assert_relative_eq!(entry(&form.a, row + 2, 2), -std::f64::consts::SQRT_2);
606        assert_relative_eq!(entry(&form.a, row + 3, 3), -std::f64::consts::SQRT_2);
607    }
608
609    #[test]
610    fn default_per_unit_coordinates_round_trip_to_si_values() {
611        let form = build_lindist3flow_standard_form(&instance()).unwrap();
612        assert_eq!(form.scaling.apparent_power_base, Some(1_000_000.0));
613        assert_relative_eq!(form.scaling.variable_scale[0], 230.0f64.powi(2));
614        assert_relative_eq!(form.scaling.variable_scale[2], 1_000_000.0);
615
616        // The physical cost rates are applied to per-unit power variables.
617        assert_relative_eq!(form.q[4], 100.0);
618        assert_relative_eq!(form.q[6], 300.0);
619
620        // Per-unit voltage bounds are normalized by each node's reference
621        // magnitude. The source voltage is fixed at 230 V.
622        assert_relative_eq!(entry(&form.a, 5, 0), -1.0);
623        assert_relative_eq!(form.b[5], -1.0);
624        assert_relative_eq!(entry(&form.a, 6, 0), 1.0);
625        assert_relative_eq!(form.b[6], 1.0);
626
627        // Canonical order is w(source), w(load), line p/q, generator p/q,
628        // source p/q. Solver values decode back to physical SI values.
629        let primal = vec![
630            1.0,
631            228.0f64.powi(2) / 230.0f64.powi(2),
632            0.001,
633            0.0002,
634            0.0005,
635            0.0,
636            0.001,
637            0.0002,
638        ];
639        let values = lindist3flow_values_from_standard_primal(&form, &primal).unwrap();
640        assert_relative_eq!(
641            values.terminal_voltage_magnitude_squared[0],
642            230.0f64.powi(2)
643        );
644        assert_relative_eq!(
645            values.terminal_voltage_magnitude_squared[1],
646            228.0f64.powi(2)
647        );
648        assert_relative_eq!(values.line_active_power[0], 1_000.0);
649        assert_relative_eq!(values.generator_active_power[0], 500.0);
650        assert_relative_eq!(values.source_reactive_power[0], 200.0);
651    }
652
653    #[test]
654    fn per_unit_form_is_an_exact_diagonal_scaling_of_si_form() {
655        let si = si_form();
656        let per_unit = build_lindist3flow_standard_form_with_options(
657            &instance(),
658            LinDist3FlowStandardFormOptions::per_unit(2_000_000.0),
659        )
660        .unwrap();
661
662        assert_eq!(per_unit.a.shape(), si.a.shape());
663        assert_eq!(per_unit.cones, si.cones);
664        assert_eq!(per_unit.row_origins, si.row_origins);
665        for column in 0..si.a.cols() {
666            for row in 0..si.a.rows() {
667                assert_relative_eq!(
668                    entry(&per_unit.a, row, column),
669                    entry(&si.a, row, column)
670                        * per_unit.scaling.variable_scale[column]
671                        * per_unit.scaling.row_scale[row],
672                    epsilon = 1e-12
673                );
674            }
675        }
676        for column in 0..si.q.len() {
677            assert_relative_eq!(
678                per_unit.q[column],
679                si.q[column] * per_unit.scaling.variable_scale[column],
680                epsilon = 1e-12
681            );
682        }
683        for row in 0..si.b.len() {
684            assert_relative_eq!(
685                per_unit.b[row],
686                si.b[row] * per_unit.scaling.row_scale[row],
687                epsilon = 1e-12
688            );
689        }
690    }
691
692    #[test]
693    fn per_unit_power_base_must_be_positive_and_finite() {
694        for power_base in [0.0, -1.0, f64::INFINITY, f64::NAN] {
695            assert!(
696                build_lindist3flow_standard_form_with_options(
697                    &instance(),
698                    LinDist3FlowStandardFormOptions::per_unit(power_base),
699                )
700                .is_err()
701            );
702        }
703    }
704}