Skip to main content

powerio_matrix/
lindist3flow_conic.rs

1//! Canonical solver-neutral conic assembly for LinDist3Flow.
2//!
3//! The output deliberately describes variables, affine equalities, bounds,
4//! and cone arguments rather than using any solver crate's model objects.
5//! A Clarabel, ECOS, or other conic adapter can therefore own the final sparse
6//! matrix convention without leaking solver state into PowerIO.
7
8use std::collections::BTreeMap;
9
10use powerio_prob::{LinDist3FlowOpfInstance, LinDist3FlowOpfValues};
11
12use crate::{
13    Error, LinDist3FlowAffineExpression, LinDist3FlowBalanceEquation, LinDist3FlowPreparation,
14    LinDist3FlowVariable, Result, build_lindist3flow_preparation,
15};
16
17/// Stable semantic identity of one scalar decision variable.
18#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
19#[non_exhaustive]
20pub enum LinDist3FlowDecisionVariable {
21    SquaredVoltage { node: usize },
22    Power(LinDist3FlowVariable),
23}
24
25/// One variable column, including its native bound and objective data.
26#[derive(Clone, Debug, PartialEq)]
27#[non_exhaustive]
28pub struct LinDist3FlowVariableData {
29    pub variable: LinDist3FlowDecisionVariable,
30    pub lower: Option<f64>,
31    pub upper: Option<f64>,
32    pub objective_coefficient: f64,
33}
34
35/// One coefficient in a canonical affine expression.
36#[derive(Clone, Copy, Debug, PartialEq)]
37#[non_exhaustive]
38pub struct LinDist3FlowLinearTerm {
39    pub column: usize,
40    pub coefficient: f64,
41}
42
43/// `constant + terms' * x` in canonical variable order.
44#[derive(Clone, Debug, Default, PartialEq)]
45#[non_exhaustive]
46pub struct LinDist3FlowLinearExpression {
47    pub constant: f64,
48    pub terms: Vec<LinDist3FlowLinearTerm>,
49}
50
51/// Physical origin of one equality constrained to zero.
52#[derive(Clone, Debug, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum LinDist3FlowEqualityOrigin {
55    LineDrop { line: usize, conductor: usize },
56    ActiveBalance { node: usize },
57    ReactiveBalance { node: usize },
58}
59
60/// One affine equality `expression == 0`.
61#[derive(Clone, Debug, PartialEq)]
62#[non_exhaustive]
63pub struct LinDist3FlowEquality {
64    pub origin: LinDist3FlowEqualityOrigin,
65    pub expression: LinDist3FlowLinearExpression,
66}
67
68/// Physical origin and meaning of one cone row block.
69#[derive(Clone, Debug, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum LinDist3FlowConeOrigin {
72    LineApparentPower {
73        line: usize,
74        conductor: usize,
75    },
76    LineCurrent {
77        line: usize,
78        conductor: usize,
79        node: usize,
80    },
81    GeneratorApparentPower {
82        generator: usize,
83        channel: usize,
84    },
85    GeneratorCurrent {
86        generator: usize,
87        channel: usize,
88    },
89}
90
91/// A standard or rotated second-order cone over affine arguments.
92///
93/// Standard arguments mean `(t, x...)` with `t >= ||x||₂`. Rotated
94/// arguments mean `(u, v, x...)` with `u, v >= 0` and
95/// `2 u v >= ||x||₂²`.
96#[derive(Clone, Debug, PartialEq)]
97#[non_exhaustive]
98pub enum LinDist3FlowCone {
99    SecondOrder {
100        origin: LinDist3FlowConeOrigin,
101        arguments: Vec<LinDist3FlowLinearExpression>,
102    },
103    RotatedSecondOrder {
104        origin: LinDist3FlowConeOrigin,
105        arguments: Vec<LinDist3FlowLinearExpression>,
106    },
107}
108
109/// Complete conic program structure in stable PowerIO variable order.
110#[derive(Clone, Debug, PartialEq)]
111#[non_exhaustive]
112pub struct LinDist3FlowConicProblem {
113    pub preparation: LinDist3FlowPreparation,
114    pub variables: Vec<LinDist3FlowVariableData>,
115    pub equalities: Vec<LinDist3FlowEquality>,
116    pub cones: Vec<LinDist3FlowCone>,
117}
118
119fn invalid(reason: impl Into<String>) -> Error {
120    Error::InvalidLinDist3FlowCoefficients {
121        reason: reason.into(),
122    }
123}
124
125fn constant(value: f64) -> LinDist3FlowLinearExpression {
126    LinDist3FlowLinearExpression {
127        constant: value,
128        terms: Vec::new(),
129    }
130}
131
132fn column(column: usize, coefficient: f64) -> LinDist3FlowLinearExpression {
133    LinDist3FlowLinearExpression {
134        constant: 0.0,
135        terms: vec![LinDist3FlowLinearTerm {
136            column,
137            coefficient,
138        }],
139    }
140}
141
142fn scaled_expression(
143    mut expression: LinDist3FlowLinearExpression,
144    scale: f64,
145) -> LinDist3FlowLinearExpression {
146    expression.constant *= scale;
147    for term in &mut expression.terms {
148        term.coefficient *= scale;
149    }
150    expression
151}
152
153fn normalized_expression(
154    constant: f64,
155    terms: impl IntoIterator<Item = (usize, f64)>,
156) -> Result<LinDist3FlowLinearExpression> {
157    if !constant.is_finite() {
158        return Err(invalid(
159            "a conic affine expression has a non-finite constant",
160        ));
161    }
162    let mut combined = BTreeMap::<usize, f64>::new();
163    for (column, coefficient) in terms {
164        if !coefficient.is_finite() {
165            return Err(invalid(format!(
166                "a conic affine expression has a non-finite coefficient at column {column}"
167            )));
168        }
169        *combined.entry(column).or_default() += coefficient;
170    }
171    Ok(LinDist3FlowLinearExpression {
172        constant,
173        terms: combined
174            .into_iter()
175            .filter_map(|(column, coefficient)| {
176                (coefficient.abs() > f64::EPSILON).then_some(LinDist3FlowLinearTerm {
177                    column,
178                    coefficient,
179                })
180            })
181            .collect(),
182    })
183}
184
185fn voltage_expression(
186    expression: &LinDist3FlowAffineExpression,
187    voltage_columns: &[usize],
188) -> Result<LinDist3FlowLinearExpression> {
189    normalized_expression(
190        expression.constant,
191        expression
192            .voltage_terms
193            .iter()
194            .map(|term| (voltage_columns[term.node], term.coefficient)),
195    )
196}
197
198fn balance_expression(
199    equation: &LinDist3FlowBalanceEquation,
200    voltage_columns: &[usize],
201    power_columns: &BTreeMap<LinDist3FlowVariable, usize>,
202) -> Result<LinDist3FlowLinearExpression> {
203    let voltage_terms = equation
204        .affine
205        .voltage_terms
206        .iter()
207        .map(|term| (voltage_columns[term.node], term.coefficient));
208    let power_terms = equation.variable_terms.iter().map(|term| {
209        power_columns
210            .get(&term.variable)
211            .copied()
212            .map(|column| (column, term.coefficient))
213            .ok_or_else(|| {
214                invalid(format!(
215                    "nodal balance references unregistered variable {:?}",
216                    term.variable
217                ))
218            })
219    });
220    normalized_expression(
221        equation.affine.constant,
222        voltage_terms.chain(power_terms.collect::<Result<Vec<_>>>()?),
223    )
224}
225
226fn push_power_variable(
227    variables: &mut Vec<LinDist3FlowVariableData>,
228    power_columns: &mut BTreeMap<LinDist3FlowVariable, usize>,
229    variable: LinDist3FlowVariable,
230    lower: Option<f64>,
231    upper: Option<f64>,
232    objective_coefficient: f64,
233) -> Result<()> {
234    if lower.is_some_and(|value| !value.is_finite())
235        || upper.is_some_and(|value| !value.is_finite())
236        || !objective_coefficient.is_finite()
237    {
238        return Err(invalid(format!(
239            "variable {variable:?} has non-finite bound or objective data"
240        )));
241    }
242    if lower.zip(upper).is_some_and(|(lower, upper)| lower > upper) {
243        return Err(invalid(format!(
244            "variable {variable:?} has inverted bounds"
245        )));
246    }
247    let column = variables.len();
248    if power_columns.insert(variable.clone(), column).is_some() {
249        return Err(invalid(format!(
250            "variable {variable:?} was registered more than once"
251        )));
252    }
253    variables.push(LinDist3FlowVariableData {
254        variable: LinDist3FlowDecisionVariable::Power(variable),
255        lower,
256        upper,
257        objective_coefficient,
258    });
259    Ok(())
260}
261
262fn power_column(
263    columns: &BTreeMap<LinDist3FlowVariable, usize>,
264    variable: &LinDist3FlowVariable,
265) -> Result<usize> {
266    columns
267        .get(variable)
268        .copied()
269        .ok_or_else(|| invalid(format!("missing conic variable {variable:?}")))
270}
271
272/// Assemble the complete supported LinDist3Flow slice as a canonical conic
273/// program.
274///
275/// Variable bounds remain native bounds. Equalities are normalized to zero.
276/// Apparent-power limits use standard SOCs. Current limits use the exact
277/// rotated representation `(w, I_max² / 2, p, q) in K_r`; line limits are
278/// enforced against the voltage at both endpoints.
279///
280/// # Errors
281/// As [`build_lindist3flow_preparation`], or an internal semantic variable is
282/// absent, duplicated, or has non-finite numerical data.
283#[allow(clippy::too_many_lines)]
284pub fn build_lindist3flow_conic_problem(
285    instance: &LinDist3FlowOpfInstance,
286) -> Result<LinDist3FlowConicProblem> {
287    let preparation = build_lindist3flow_preparation(instance)?;
288    let mut variables = Vec::new();
289    let mut voltage_columns = Vec::with_capacity(preparation.network.nodes.len());
290    for (node, data) in preparation.network.nodes.iter().enumerate() {
291        let column = variables.len();
292        voltage_columns.push(column);
293        let (lower, upper) = if let Some(fixed) = data.fixed_squared_voltage {
294            if data
295                .squared_voltage_min
296                .is_some_and(|minimum| fixed < minimum)
297                || data
298                    .squared_voltage_max
299                    .is_some_and(|maximum| fixed > maximum)
300            {
301                return Err(invalid(format!(
302                    "fixed voltage at node {node} is outside its selected voltage bounds"
303                )));
304            }
305            (Some(fixed), Some(fixed))
306        } else {
307            // Squared magnitudes remain nonnegative without operating limits.
308            (
309                Some(data.squared_voltage_min.unwrap_or(0.0)),
310                data.squared_voltage_max,
311            )
312        };
313        variables.push(LinDist3FlowVariableData {
314            variable: LinDist3FlowDecisionVariable::SquaredVoltage { node },
315            lower,
316            upper,
317            objective_coefficient: 0.0,
318        });
319    }
320
321    let mut power_columns = BTreeMap::new();
322    for (line, data) in preparation.network.lines.iter().enumerate() {
323        for conductor in 0..data.parent_nodes.len() {
324            push_power_variable(
325                &mut variables,
326                &mut power_columns,
327                LinDist3FlowVariable::LineActive { line, conductor },
328                None,
329                None,
330                0.0,
331            )?;
332            push_power_variable(
333                &mut variables,
334                &mut power_columns,
335                LinDist3FlowVariable::LineReactive { line, conductor },
336                None,
337                None,
338                0.0,
339            )?;
340        }
341    }
342    for (generator, data) in preparation.devices.generators.iter().enumerate() {
343        for (channel, channel_data) in data.channels.iter().enumerate() {
344            push_power_variable(
345                &mut variables,
346                &mut power_columns,
347                LinDist3FlowVariable::GeneratorActive { generator, channel },
348                channel_data.active_min,
349                channel_data.active_max,
350                channel_data.active_objective_coefficient,
351            )?;
352            push_power_variable(
353                &mut variables,
354                &mut power_columns,
355                LinDist3FlowVariable::GeneratorReactive { generator, channel },
356                channel_data.reactive_min,
357                channel_data.reactive_max,
358                0.0,
359            )?;
360        }
361    }
362    for (source, data) in preparation.devices.sources.iter().enumerate() {
363        for (channel, &objective) in data.active_objective_coefficient.iter().enumerate() {
364            push_power_variable(
365                &mut variables,
366                &mut power_columns,
367                LinDist3FlowVariable::SourceActive { source, channel },
368                None,
369                None,
370                objective,
371            )?;
372            push_power_variable(
373                &mut variables,
374                &mut power_columns,
375                LinDist3FlowVariable::SourceReactive { source, channel },
376                None,
377                None,
378                0.0,
379            )?;
380        }
381    }
382
383    let mut equalities = Vec::new();
384    for (line, data) in preparation.network.lines.iter().enumerate() {
385        for conductor in 0..data.parent_nodes.len() {
386            let terms = [
387                (voltage_columns[data.child_nodes[conductor]], 1.0),
388                (voltage_columns[data.parent_nodes[conductor]], -1.0),
389            ]
390            .into_iter()
391            .chain(
392                data.drop.active[conductor]
393                    .iter()
394                    .enumerate()
395                    .map(|(other, &coefficient)| {
396                        let column = power_column(
397                            &power_columns,
398                            &LinDist3FlowVariable::LineActive {
399                                line,
400                                conductor: other,
401                            },
402                        );
403                        column.map(|column| (column, coefficient))
404                    })
405                    .collect::<Result<Vec<_>>>()?,
406            )
407            .chain(
408                data.drop.reactive[conductor]
409                    .iter()
410                    .enumerate()
411                    .map(|(other, &coefficient)| {
412                        let column = power_column(
413                            &power_columns,
414                            &LinDist3FlowVariable::LineReactive {
415                                line,
416                                conductor: other,
417                            },
418                        );
419                        column.map(|column| (column, coefficient))
420                    })
421                    .collect::<Result<Vec<_>>>()?,
422            );
423            equalities.push(LinDist3FlowEquality {
424                origin: LinDist3FlowEqualityOrigin::LineDrop { line, conductor },
425                expression: normalized_expression(0.0, terms)?,
426            });
427        }
428    }
429    for balance in &preparation.devices.balances {
430        equalities.push(LinDist3FlowEquality {
431            origin: LinDist3FlowEqualityOrigin::ActiveBalance { node: balance.node },
432            expression: balance_expression(&balance.active, &voltage_columns, &power_columns)?,
433        });
434        equalities.push(LinDist3FlowEquality {
435            origin: LinDist3FlowEqualityOrigin::ReactiveBalance { node: balance.node },
436            expression: balance_expression(&balance.reactive, &voltage_columns, &power_columns)?,
437        });
438    }
439
440    let mut cones = Vec::new();
441    for (line, data) in preparation.network.lines.iter().enumerate() {
442        for conductor in 0..data.parent_nodes.len() {
443            let active = power_column(
444                &power_columns,
445                &LinDist3FlowVariable::LineActive { line, conductor },
446            )?;
447            let reactive = power_column(
448                &power_columns,
449                &LinDist3FlowVariable::LineReactive { line, conductor },
450            )?;
451            if let Some(limit) = data.apparent_power_limit[conductor] {
452                cones.push(LinDist3FlowCone::SecondOrder {
453                    origin: LinDist3FlowConeOrigin::LineApparentPower { line, conductor },
454                    arguments: vec![constant(limit), column(active, 1.0), column(reactive, 1.0)],
455                });
456            }
457            if let Some(limit) = data.current_limit[conductor] {
458                for &node in &[data.parent_nodes[conductor], data.child_nodes[conductor]] {
459                    let reference_voltage = preparation.network.nodes[node].reference_magnitude;
460                    cones.push(LinDist3FlowCone::RotatedSecondOrder {
461                        origin: LinDist3FlowConeOrigin::LineCurrent {
462                            line,
463                            conductor,
464                            node,
465                        },
466                        arguments: vec![
467                            column(voltage_columns[node], limit / reference_voltage),
468                            constant(limit * reference_voltage / 2.0),
469                            column(active, 1.0),
470                            column(reactive, 1.0),
471                        ],
472                    });
473                }
474            }
475        }
476    }
477    for (generator, data) in preparation.devices.generators.iter().enumerate() {
478        for (channel, channel_data) in data.channels.iter().enumerate() {
479            let active = power_column(
480                &power_columns,
481                &LinDist3FlowVariable::GeneratorActive { generator, channel },
482            )?;
483            let reactive = power_column(
484                &power_columns,
485                &LinDist3FlowVariable::GeneratorReactive { generator, channel },
486            )?;
487            if let Some(limit) = channel_data.apparent_power_limit {
488                cones.push(LinDist3FlowCone::SecondOrder {
489                    origin: LinDist3FlowConeOrigin::GeneratorApparentPower { generator, channel },
490                    arguments: vec![constant(limit), column(active, 1.0), column(reactive, 1.0)],
491                });
492            }
493            if let Some(limit) = channel_data.current_limit {
494                let reference_voltage = channel_data.reference_winding_voltage;
495                cones.push(LinDist3FlowCone::RotatedSecondOrder {
496                    origin: LinDist3FlowConeOrigin::GeneratorCurrent { generator, channel },
497                    arguments: vec![
498                        scaled_expression(
499                            voltage_expression(
500                                &channel_data.squared_winding_voltage,
501                                &voltage_columns,
502                            )?,
503                            limit / reference_voltage,
504                        ),
505                        constant(limit * reference_voltage / 2.0),
506                        column(active, 1.0),
507                        column(reactive, 1.0),
508                    ],
509                });
510            }
511        }
512    }
513
514    Ok(LinDist3FlowConicProblem {
515        preparation,
516        variables,
517        equalities,
518        cones,
519    })
520}
521
522/// Translate a canonical primal vector back to formulation-ordered physical
523/// values suitable for [`powerio_prob::LinDist3FlowOpfSolution`].
524///
525/// # Errors
526/// The primal length differs from the canonical variable count, or the
527/// problem contains an inconsistent semantic index.
528#[allow(clippy::too_many_lines)]
529pub fn lindist3flow_values_from_primal(
530    problem: &LinDist3FlowConicProblem,
531    primal: &[f64],
532) -> Result<LinDist3FlowOpfValues> {
533    if primal.len() != problem.variables.len() {
534        return Err(invalid(format!(
535            "LinDist3Flow primal has length {}, expected {}",
536            primal.len(),
537            problem.variables.len()
538        )));
539    }
540    let line_offsets = problem
541        .preparation
542        .network
543        .lines
544        .iter()
545        .scan(0, |offset, line| {
546            let current = *offset;
547            *offset += line.parent_nodes.len();
548            Some(current)
549        })
550        .collect::<Vec<_>>();
551    let generator_offsets = problem
552        .preparation
553        .devices
554        .generators
555        .iter()
556        .scan(0, |offset, generator| {
557            let current = *offset;
558            *offset += generator.channels.len();
559            Some(current)
560        })
561        .collect::<Vec<_>>();
562    let source_offsets = problem
563        .preparation
564        .devices
565        .sources
566        .iter()
567        .scan(0, |offset, source| {
568            let current = *offset;
569            *offset += source.terminal_nodes.len();
570            Some(current)
571        })
572        .collect::<Vec<_>>();
573    let line_count = problem
574        .preparation
575        .network
576        .lines
577        .iter()
578        .map(|line| line.parent_nodes.len())
579        .sum();
580    let generator_count = problem
581        .preparation
582        .devices
583        .generators
584        .iter()
585        .map(|generator| generator.channels.len())
586        .sum();
587    let source_count = problem
588        .preparation
589        .devices
590        .sources
591        .iter()
592        .map(|source| source.terminal_nodes.len())
593        .sum();
594    let mut values = LinDist3FlowOpfValues::default();
595    values.terminal_voltage_magnitude_squared = vec![0.0; problem.preparation.network.nodes.len()];
596    values.line_active_power = vec![0.0; line_count];
597    values.line_reactive_power = vec![0.0; line_count];
598    values.generator_active_power = vec![0.0; generator_count];
599    values.generator_reactive_power = vec![0.0; generator_count];
600    values.source_active_power = vec![0.0; source_count];
601    values.source_reactive_power = vec![0.0; source_count];
602    for (column, variable) in problem.variables.iter().enumerate() {
603        let value = primal[column];
604        match &variable.variable {
605            LinDist3FlowDecisionVariable::SquaredVoltage { node } => {
606                *values
607                    .terminal_voltage_magnitude_squared
608                    .get_mut(*node)
609                    .ok_or_else(|| invalid(format!("unknown voltage node {node}")))? = value;
610            }
611            LinDist3FlowDecisionVariable::Power(power) => match power {
612                LinDist3FlowVariable::LineActive { line, conductor } => {
613                    let position = line_offsets
614                        .get(*line)
615                        .copied()
616                        .and_then(|offset| offset.checked_add(*conductor))
617                        .ok_or_else(|| invalid("unknown line active-power index"))?;
618                    *values
619                        .line_active_power
620                        .get_mut(position)
621                        .ok_or_else(|| invalid("unknown line active-power conductor"))? = value;
622                }
623                LinDist3FlowVariable::LineReactive { line, conductor } => {
624                    let position = line_offsets
625                        .get(*line)
626                        .copied()
627                        .and_then(|offset| offset.checked_add(*conductor))
628                        .ok_or_else(|| invalid("unknown line reactive-power index"))?;
629                    *values
630                        .line_reactive_power
631                        .get_mut(position)
632                        .ok_or_else(|| invalid("unknown line reactive-power conductor"))? = value;
633                }
634                LinDist3FlowVariable::GeneratorActive { generator, channel } => {
635                    let position = generator_offsets
636                        .get(*generator)
637                        .copied()
638                        .and_then(|offset| offset.checked_add(*channel))
639                        .ok_or_else(|| invalid("unknown generator active-power index"))?;
640                    *values
641                        .generator_active_power
642                        .get_mut(position)
643                        .ok_or_else(|| invalid("unknown generator active-power channel"))? = value;
644                }
645                LinDist3FlowVariable::GeneratorReactive { generator, channel } => {
646                    let position = generator_offsets
647                        .get(*generator)
648                        .copied()
649                        .and_then(|offset| offset.checked_add(*channel))
650                        .ok_or_else(|| invalid("unknown generator reactive-power index"))?;
651                    *values
652                        .generator_reactive_power
653                        .get_mut(position)
654                        .ok_or_else(|| invalid("unknown generator reactive-power channel"))? =
655                        value;
656                }
657                LinDist3FlowVariable::SourceActive { source, channel } => {
658                    let position = source_offsets
659                        .get(*source)
660                        .copied()
661                        .and_then(|offset| offset.checked_add(*channel))
662                        .ok_or_else(|| invalid("unknown source active-power index"))?;
663                    *values
664                        .source_active_power
665                        .get_mut(position)
666                        .ok_or_else(|| invalid("unknown source active-power channel"))? = value;
667                }
668                LinDist3FlowVariable::SourceReactive { source, channel } => {
669                    let position = source_offsets
670                        .get(*source)
671                        .copied()
672                        .and_then(|offset| offset.checked_add(*channel))
673                        .ok_or_else(|| invalid("unknown source reactive-power index"))?;
674                    *values
675                        .source_reactive_power
676                        .get_mut(position)
677                        .ok_or_else(|| invalid("unknown source reactive-power channel"))? = value;
678                }
679            },
680        }
681    }
682    Ok(values)
683}
684
685#[cfg(test)]
686mod tests {
687    use approx::assert_relative_eq;
688    use powerio_dist::{
689        Configuration, DistBus, DistGenerator, DistLine, DistLineCode, MulticonductorNetwork,
690        VoltageSource,
691    };
692    use powerio_prob::{LinDist3FlowBuildOptions, LinDist3FlowOpfInstance};
693
694    use super::*;
695
696    fn instance() -> LinDist3FlowOpfInstance {
697        let terminal = vec!["1".to_owned()];
698        let mut network = MulticonductorNetwork::named("conic");
699        let mut source_bus = DistBus::new("source", terminal.clone());
700        source_bus.v_min = Some(220.0);
701        source_bus.v_max = Some(240.0);
702        network.buses_mut().push(source_bus);
703        let mut load_bus = DistBus::new("load", terminal.clone());
704        load_bus.v_min = Some(210.0);
705        load_bus.v_max = Some(240.0);
706        network.buses_mut().push(load_bus);
707        let mut code = DistLineCode::new("one", vec![vec![0.1]], vec![vec![0.2]]);
708        code.i_max = Some(vec![10.0]);
709        code.s_max = Some(vec![2_000.0]);
710        network.line_codes_mut().push(code);
711        network.lines_mut().push(DistLine::new(
712            "line",
713            "source",
714            "load",
715            terminal.clone(),
716            terminal.clone(),
717            "one",
718            1.0,
719        ));
720        let mut source =
721            VoltageSource::new("grid", "source", terminal.clone(), vec![230.0], vec![0.0]);
722        source.energy_cost_rate = Some(vec![0.3]);
723        network.sources_mut().push(source);
724        let mut generator = DistGenerator::new(
725            "pv",
726            "load",
727            terminal,
728            Configuration::Wye,
729            vec![500.0],
730            vec![0.0],
731        );
732        generator.p_min = Some(vec![0.0]);
733        generator.p_max = Some(vec![1_000.0]);
734        generator.q_min = Some(vec![-500.0]);
735        generator.q_max = Some(vec![500.0]);
736        generator.s_max = Some(vec![1_100.0]);
737        generator.i_max = Some(vec![5.0]);
738        generator.cost = Some(vec![0.1]);
739        network.generators_mut().push(generator);
740        LinDist3FlowOpfInstance::from_network(network, LinDist3FlowBuildOptions::default()).unwrap()
741    }
742
743    fn coefficient(expression: &LinDist3FlowLinearExpression, column: usize) -> f64 {
744        expression
745            .terms
746            .iter()
747            .find(|term| term.column == column)
748            .map_or(0.0, |term| term.coefficient)
749    }
750
751    #[test]
752    fn assembly_has_stable_variables_equalities_and_objective() {
753        let problem = build_lindist3flow_conic_problem(&instance()).unwrap();
754        assert_eq!(problem.variables.len(), 8);
755        assert_eq!(problem.equalities.len(), 5);
756        assert_eq!(
757            problem.variables[0].variable,
758            LinDist3FlowDecisionVariable::SquaredVoltage { node: 0 }
759        );
760        assert_eq!(problem.variables[0].lower, Some(230.0f64.powi(2)));
761        assert_eq!(problem.variables[0].upper, Some(230.0f64.powi(2)));
762        assert_relative_eq!(problem.variables[4].objective_coefficient, 0.1 / 1000.0);
763        assert_relative_eq!(problem.variables[6].objective_coefficient, 0.3 / 1000.0);
764        assert_eq!(
765            problem.equalities[0].origin,
766            LinDist3FlowEqualityOrigin::LineDrop {
767                line: 0,
768                conductor: 0
769            }
770        );
771    }
772
773    #[test]
774    fn line_drop_and_balances_reference_canonical_columns() {
775        let problem = build_lindist3flow_conic_problem(&instance()).unwrap();
776        let drop = &problem.equalities[0].expression;
777        assert_relative_eq!(coefficient(drop, 0), -1.0);
778        assert_relative_eq!(coefficient(drop, 1), 1.0);
779        assert_relative_eq!(coefficient(drop, 2), 0.2);
780        assert_relative_eq!(coefficient(drop, 3), 0.4);
781
782        let source_active = &problem.equalities[1].expression;
783        assert_relative_eq!(coefficient(source_active, 2), -1.0);
784        assert_relative_eq!(coefficient(source_active, 6), 1.0);
785        let load_active = &problem.equalities[3].expression;
786        assert_relative_eq!(coefficient(load_active, 2), 1.0);
787        assert_relative_eq!(coefficient(load_active, 4), 1.0);
788    }
789
790    #[test]
791    fn line_and_generator_limits_become_native_cones() {
792        let problem = build_lindist3flow_conic_problem(&instance()).unwrap();
793        assert_eq!(problem.cones.len(), 5);
794        let LinDist3FlowCone::SecondOrder { origin, arguments } = &problem.cones[0] else {
795            panic!("first limit should be a standard SOC")
796        };
797        assert_eq!(
798            origin,
799            &LinDist3FlowConeOrigin::LineApparentPower {
800                line: 0,
801                conductor: 0
802            }
803        );
804        assert_relative_eq!(arguments[0].constant, 2_000.0);
805
806        let LinDist3FlowCone::RotatedSecondOrder { origin, arguments } = &problem.cones[1] else {
807            panic!("line current should use a rotated SOC")
808        };
809        assert!(matches!(
810            origin,
811            LinDist3FlowConeOrigin::LineCurrent { node: 0, .. }
812        ));
813        assert_relative_eq!(arguments[1].constant, 1_150.0);
814        assert_relative_eq!(coefficient(&arguments[0], 0), 10.0 / 230.0);
815
816        let LinDist3FlowCone::RotatedSecondOrder { origin, arguments } = &problem.cones[4] else {
817            panic!("generator current should use a rotated SOC")
818        };
819        assert_eq!(
820            origin,
821            &LinDist3FlowConeOrigin::GeneratorCurrent {
822                generator: 0,
823                channel: 0
824            }
825        );
826        assert_relative_eq!(arguments[1].constant, 575.0);
827    }
828
829    #[test]
830    fn canonical_primal_translates_to_physical_table_order() {
831        let problem = build_lindist3flow_conic_problem(&instance()).unwrap();
832        let primal = (0..problem.variables.len())
833            .map(|column| column as f64 + 10.0)
834            .collect::<Vec<_>>();
835        let values = lindist3flow_values_from_primal(&problem, &primal).unwrap();
836        assert_eq!(values.terminal_voltage_magnitude_squared, vec![10.0, 11.0]);
837        assert_eq!(values.line_active_power, vec![12.0]);
838        assert_eq!(values.line_reactive_power, vec![13.0]);
839        assert_eq!(values.generator_active_power, vec![14.0]);
840        assert_eq!(values.generator_reactive_power, vec![15.0]);
841        assert_eq!(values.source_active_power, vec![16.0]);
842        assert_eq!(values.source_reactive_power, vec![17.0]);
843        assert!(lindist3flow_values_from_primal(&problem, &primal[..7]).is_err());
844    }
845}