Skip to main content

powerio_matrix/dcopf/
prep.rs

1use serde::{Deserialize, Serialize};
2
3use powerio_tx::{BalancedNetwork, BranchSusceptanceFormula, BusId, IndexedNetwork};
4
5use crate::{AnalysisBranchSource, Error, Result};
6use powerio_prob::ReferenceBuses;
7
8use super::{limits, nodal};
9use crate::{PiecewiseLinearCost, PreparedObjective};
10
11/// Unit system for power and generator cost data.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum Units {
15    /// Power is per unit. Cost coefficients are scaled for per unit power.
16    #[default]
17    PerUnit,
18    /// Power remains in the source unit, normally MW.
19    Native,
20}
21
22impl std::str::FromStr for Units {
23    type Err = String;
24
25    /// The one alias table for the bindings: `per-unit`/`perunit`/`pu` and
26    /// `native`, case insensitive, `-`/`_` ignored.
27    fn from_str(name: &str) -> std::result::Result<Self, Self::Err> {
28        match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
29            "perunit" | "pu" => Ok(Units::PerUnit),
30            "native" => Ok(Units::Native),
31            other => Err(format!(
32                "unknown units `{other}`; expected \"per-unit\" or \"native\""
33            )),
34        }
35    }
36}
37
38impl Units {
39    /// `(power, admittance)` multipliers for source data on `base` MVA. MW
40    /// valued quantities (demand, bounds, limits, MW valued shunts) scale by
41    /// the first; per unit admittances and susceptances by the second.
42    pub(crate) fn power_scales(self, base: f64) -> (f64, f64) {
43        match self {
44            Self::PerUnit => (1.0 / base, 1.0),
45            Self::Native => (1.0, base),
46        }
47    }
48
49    /// `(quadratic, linear)` generator cost coefficient multipliers for the
50    /// same unit selection. The constant term never scales.
51    pub(crate) fn cost_scales(self, base: f64) -> (f64, f64) {
52        match self {
53            Self::PerUnit => (base * base, base),
54            Self::Native => (1.0, 1.0),
55        }
56    }
57}
58
59/// Options for DC OPF instance assembly.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub struct DcOpfOptions {
62    /// Formula used to calculate each branch susceptance.
63    pub formula: BranchSusceptanceFormula,
64    pub units: Units,
65    /// Skip non-self-loop branches with zero reactance. Off by default:
66    /// zero impedance branches are preserved in networks and instances, so
67    /// assembly refuses them with [`powerio_tx::Error::ZeroImpedance`] until
68    /// the caller resolves them explicitly
69    /// ([`powerio_prob::merge_zero_impedance_buses`]) or opts into skipping.
70    pub skip_zero_impedance: bool,
71    /// Give a branch with no thermal rating the bound
72    /// [`Branch::synthesize_rate_a`](powerio_tx::Branch::synthesize_rate_a)
73    /// states. If false, `rate_a <= 0` reaches `f_max` as zero, which reads as
74    /// unlimited. `#[serde(default)]`: documents serialized before the field
75    /// existed deserialize to the default (off), the pre-field behavior.
76    #[serde(default)]
77    pub synthesize_unrated_limits: bool,
78    /// Apply PowerModels' ±60 degree correction to unconstrained or unusable
79    /// branch angle difference intervals in the prepared arrays.
80    #[serde(default = "default_true")]
81    pub correct_angle_difference_bounds: bool,
82    /// The already validated instance objective to compile into the arrays.
83    pub objective: PreparedObjective,
84}
85
86const fn default_true() -> bool {
87    true
88}
89
90impl Default for DcOpfOptions {
91    fn default() -> Self {
92        Self {
93            formula: BranchSusceptanceFormula::default(),
94            units: Units::default(),
95            skip_zero_impedance: false,
96            synthesize_unrated_limits: false,
97            correct_angle_difference_bounds: true,
98            objective: PreparedObjective::default(),
99        }
100    }
101}
102
103/// Generator parameters in generator column order.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[non_exhaustive]
106pub struct DcGeneratorParameters {
107    /// Stable generator identity aligned with every following column.
108    pub identities: Vec<String>,
109    /// Generator column to dense bus index.
110    pub bus_of_gen: Vec<usize>,
111    /// Generator column to row in the star-lowered analysis network.
112    pub analysis_rows: Vec<usize>,
113    /// Generator column to source generator row. A synthetic analysis row has
114    /// no source row.
115    pub source_rows: Vec<Option<usize>>,
116    /// Quadratic objective diagonal in `0.5 * q * p^2 + c * p + c0`.
117    pub q: Vec<f64>,
118    /// Linear objective coefficient.
119    pub c: Vec<f64>,
120    /// Constant objective term. Unscaled in both unit systems: it carries no
121    /// power dimension. It does not move the argmin, but a consumer reporting
122    /// or comparing objective values needs it.
123    pub c0: Vec<f64>,
124    /// Convex piecewise linear costs aligned with the generator columns.
125    ///
126    /// `Some` is the complete objective term for that generator; its `q`, `c`,
127    /// and `c0` entries above are zero. `None` means the three polynomial
128    /// columns carry the complete constant, linear, or quadratic term.
129    pub piecewise_linear: Vec<Option<PiecewiseLinearCost>>,
130    pub pmax: Vec<f64>,
131    pub pmin: Vec<f64>,
132    /// Whether the instance activates this generator's capability bounds.
133    pub capability_active: Vec<bool>,
134}
135
136/// Branch parameters in active branch column order.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[non_exhaustive]
139pub struct DcBranchParameters {
140    /// Stable branch identity aligned with every following column.
141    pub identities: Vec<String>,
142    pub from_bus: Vec<usize>,
143    pub to_bus: Vec<usize>,
144    /// Branch susceptance in the selected power unit per radian, positive for
145    /// an inductive branch.
146    pub susceptance_magnitude: Vec<f64>,
147    /// Phase shift in radians. Zero unless the formula carries phase shift
148    /// injections.
149    pub shift: Vec<f64>,
150    /// Thermal limit in the selected power unit. Zero means unlimited.
151    pub f_max: Vec<f64>,
152    /// Branch angle bounds in radians.
153    pub angle_min: Vec<f64>,
154    pub angle_max: Vec<f64>,
155    /// Branch column to row in the star-lowered analysis network.
156    pub analysis_rows: Vec<usize>,
157    /// Source component for each analysis branch column. Lowered transformer
158    /// windings remain mapped to their typed transformer row and winding.
159    pub analysis_sources: Vec<AnalysisBranchSource>,
160    /// Analysis branch rows omitted because their reactance was zero.
161    pub skipped_zero_impedance: Vec<usize>,
162    /// Whether the instance activates each thermal limit.
163    pub thermal_limit_active: Vec<bool>,
164    /// Whether the instance activates each angle difference bound.
165    pub angle_bound_active: Vec<bool>,
166}
167
168/// Generator parameters in dense bus order, aggregated over the generators at each
169/// bus. See [`DcOpfPreparation::calc_nodal_generator_data`].
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct NodalGeneratorParameters {
173    pub q: Vec<f64>,
174    pub c: Vec<f64>,
175    pub c0: Vec<f64>,
176    pub pmax: Vec<f64>,
177    pub pmin: Vec<f64>,
178    /// Which buses host a generator. A bus without one has a zero range and a
179    /// zero cost, which a formulation must not read as a free generator.
180    pub has_gen: Vec<bool>,
181}
182
183/// Matrix free DC OPF input data.
184///
185/// A problem instance is complete numerical input for one problem family. It
186/// is separate from the source network, a matrix projection, a solver
187/// formulation, and a solution.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[non_exhaustive]
190pub struct DcOpfPreparation {
191    pub name: String,
192    pub n_buses: usize,
193    pub n_source_generators: usize,
194    pub n_source_branches: usize,
195    pub base_mva: f64,
196    pub units: Units,
197    /// Formula used to calculate each branch susceptance.
198    pub formula: BranchSusceptanceFormula,
199    /// The objective represented by the generator cost columns.
200    pub objective: PreparedObjective,
201    pub skip_zero_impedance: bool,
202    /// Whether zero and negative source thermal ratings were replaced with
203    /// synthesized limits while assembling this instance.
204    ///
205    /// `#[serde(default)]` keeps documents written before this field readable;
206    /// their limits retain the old unsynthesized meaning.
207    #[serde(default)]
208    pub synthesize_unrated_limits: bool,
209    /// Whether PowerModels' angle difference correction was applied.
210    pub correct_angle_difference_bounds: bool,
211    /// Dense bus index to external bus ID.
212    pub bus_ids: Vec<BusId>,
213    /// Dense bus index to row in the star-lowered analysis network.
214    pub bus_analysis_rows: Vec<usize>,
215    /// Dense bus index to source bus row. A synthetic star bus has no source
216    /// row; an explicitly isolated source bus has no dense row here.
217    pub bus_source_rows: Vec<Option<usize>>,
218    pub reference_buses: ReferenceBuses,
219    /// Nodal active demand in dense bus order.
220    pub p_d: Vec<f64>,
221    /// Nodal shunt conductance in dense bus order.
222    ///
223    /// The DC power flow model holds voltage magnitude at one per unit, so a
224    /// shunt draws the constant real power `g_s` and does not depend on the
225    /// angle. It belongs in the injection: the bus susceptance matrix keeps
226    /// zero row sums and carries no shunt. A nodal balance subtracts it
227    /// beside [`Self::p_d`], as MATPOWER `runpf` does.
228    pub g_s: Vec<f64>,
229    /// Nodal phase shift injection in dense bus order. The complete fixed
230    /// withdrawal in `L theta = Cg pg - fixed` is `p_d + g_s + p_shift`.
231    pub p_shift: Vec<f64>,
232    pub generators: DcGeneratorParameters,
233    pub branches: DcBranchParameters,
234}
235
236impl DcOpfPreparation {
237    #[must_use]
238    pub fn n_generators(&self) -> usize {
239        self.generators.q.len()
240    }
241
242    #[must_use]
243    pub fn n_branches(&self) -> usize {
244        self.branches.susceptance_magnitude.len()
245    }
246
247    /// Fixed nodal withdrawal in dense bus order.
248    ///
249    /// With `A` oriented from bus to bus and
250    /// `L = A diag(b) A^T`, the DC balance is
251    /// `L theta = Cg pg - (p_d + g_s + p_shift)`.
252    #[must_use]
253    pub fn calc_fixed_nodal_withdrawal(&self) -> Vec<f64> {
254        (0..self.n_buses)
255            .map(|bus| self.p_d[bus] + self.g_s[bus] + self.p_shift[bus])
256            .collect()
257    }
258
259    /// Fixed branch flow offset in active branch column order.
260    ///
261    /// The complete branch flow over this preparation's internal positive
262    /// weights is `f = diag(b) A^T theta + branch_flow_offset`, where the
263    /// offset is `-b * shift` elementwise. In the public PowerModels sign
264    /// spelling the same flow is `p_branch = -Bf va + b .* shift` with the
265    /// negated susceptances ([`crate::DcOperators`] emits that
266    /// form); the two agree term for term because this `b` is the negation
267    /// of the public one.
268    #[must_use]
269    pub fn calc_branch_flow_offset(&self) -> Vec<f64> {
270        (0..self.n_branches())
271            .map(|branch| {
272                -self.branches.susceptance_magnitude[branch] * self.branches.shift[branch]
273            })
274            .collect()
275    }
276
277    /// Project generator cost and bounds to bus space.
278    ///
279    /// The bounds at a bus are the sum of the generator bounds, which is the
280    /// range the bus total can reach. The cost curves at a bus combine by the
281    /// parallel rule `q = 1 / Σ(1/qᵢ)`, the curve that the least cost split of
282    /// the bus total follows. That combination is an approximation: it agrees
283    /// with generator space only while the split stays inside the bound of
284    /// each generator. A bus with one generator keeps that generator's own
285    /// coefficients.
286    pub fn calc_nodal_generator_data(&self) -> Result<NodalGeneratorParameters> {
287        let n = self.n_buses;
288        let generators = &self.generators;
289        if let Some(gen_index) = generators.piecewise_linear.iter().position(Option::is_some) {
290            return Err(Error::PiecewiseNodalCost { gen_index });
291        }
292        let bus_of_gen = &generators.bus_of_gen;
293        let costs =
294            nodal::combine_costs(n, bus_of_gen, &generators.q, &generators.c, &generators.c0);
295        Ok(NodalGeneratorParameters {
296            q: costs.q,
297            c: costs.c,
298            c0: costs.c0,
299            pmax: nodal::sum_by_bus(n, bus_of_gen, &generators.pmax),
300            pmin: nodal::sum_by_bus(n, bus_of_gen, &generators.pmin),
301            has_gen: nodal::buses_with_generators(n, bus_of_gen),
302        })
303    }
304}
305
306/// Build the matrix free DC OPF arrays from an indexed network view. The
307/// public instance level entry is
308/// [`build_dc_opf_preparation`](crate::build_dc_opf_preparation), which
309/// derives the view and the options from a
310/// [`DcOpfInstance`](powerio_prob::DcOpfInstance).
311#[allow(clippy::too_many_lines)]
312pub(crate) fn preparation_from_view(
313    case: &IndexedNetwork,
314    options: DcOpfOptions,
315) -> Result<DcOpfPreparation> {
316    case.network().check_base_mva()?;
317
318    let active_buses = crate::opf::active_bus_index(case)?;
319    let n_buses = active_buses.analysis_rows.len();
320    let base = case.per_unit_base();
321    let (p_scale, b_scale) = options.units.power_scales(base);
322    let thermal = limits::ThermalLimits {
323        synthesize_unrated: options.synthesize_unrated_limits,
324        power_scale: p_scale,
325        admittance_scale: b_scale,
326    };
327    let (q_scale, c_scale) = options.units.cost_scales(base);
328
329    let mut bus_of_gen = Vec::new();
330    let mut generator_identities = Vec::new();
331    let mut generator_rows = Vec::new();
332    let mut q = Vec::new();
333    let mut c = Vec::new();
334    let mut c0 = Vec::new();
335    let mut piecewise_linear = Vec::new();
336    let mut pmax = Vec::new();
337    let mut pmin = Vec::new();
338
339    for (source_row, generator) in case.in_service_gens() {
340        let analysis_bus = case
341            .bus_index(generator.bus)
342            .ok_or(powerio_tx::Error::UnknownBus {
343                bus_id: generator.bus,
344                element_index: source_row,
345            })?;
346        let Some(bus) = active_buses.dense_by_analysis[analysis_bus] else {
347            continue;
348        };
349        let terms = match options.objective {
350            PreparedObjective::Feasibility => nodal::GeneratorCostTerms {
351                q: 0.0,
352                c: 0.0,
353                c0: 0.0,
354                piecewise_linear: None,
355            },
356            PreparedObjective::NetworkGeneratorCost => {
357                let cost = generator
358                    .cost
359                    .as_ref()
360                    .ok_or(powerio_tx::Error::MissingGenCost {
361                        gen_index: source_row,
362                    })?;
363                nodal::generator_cost_terms(cost, source_row, p_scale)?
364            }
365        };
366        generator_identities.push(crate::opf::row_identity(
367            generator.uid.as_deref(),
368            "generators",
369            source_row,
370        ));
371        bus_of_gen.push(bus);
372        generator_rows.push(source_row);
373        q.push(terms.q * q_scale);
374        c.push(terms.c * c_scale);
375        c0.push(terms.c0);
376        piecewise_linear.push(terms.piecewise_linear);
377        pmax.push(generator.pmax * p_scale);
378        pmin.push(generator.pmin * p_scale);
379    }
380    if q.is_empty() {
381        return Err(Error::NoGenerators);
382    }
383
384    let mut from_bus = Vec::new();
385    let mut branch_identities = Vec::new();
386    let mut to_bus = Vec::new();
387    let mut b = Vec::new();
388    let mut shift = Vec::new();
389    let mut f_max = Vec::new();
390    let mut angle_min = Vec::new();
391    let mut angle_max = Vec::new();
392    let mut branch_rows = Vec::new();
393    let mut skipped_zero_impedance = Vec::new();
394    let mut p_shift = vec![0.0; n_buses];
395    // Dense bus order is the position order of `network().buses`.
396    let buses = &case.network().buses();
397
398    for (source_row, branch) in case.in_service_branches() {
399        let from_analysis = case
400            .bus_index(branch.from)
401            .ok_or(powerio_tx::Error::UnknownBus {
402                bus_id: branch.from,
403                element_index: source_row,
404            })?;
405        let to_analysis = case
406            .bus_index(branch.to)
407            .ok_or(powerio_tx::Error::UnknownBus {
408                bus_id: branch.to,
409                element_index: source_row,
410            })?;
411        let (Some(from), Some(to)) = (
412            active_buses.dense_by_analysis[from_analysis],
413            active_buses.dense_by_analysis[to_analysis],
414        ) else {
415            continue;
416        };
417        if from == to {
418            // A self-loop carries no angle difference, so it contributes no
419            // DC flow, and its shift injection cancels at its own bus.
420            continue;
421        }
422        // The reactance the DC matrix builders bound, on the same rule: an
423        // `x = 1e-300` gives a finite `b = 1e300` that annihilates every real
424        // branch sharing a bus with it. Exact zero used to be the whole test.
425        if branch.x.abs() < powerio_tx::dc::MIN_DIVISIBLE_MAGNITUDE {
426            if options.skip_zero_impedance {
427                skipped_zero_impedance.push(source_row);
428                continue;
429            }
430            return Err(powerio_tx::Error::ZeroImpedance { row: source_row }.into());
431        }
432        // Only the tap-reading formula can be bounded by a tap (#324).
433        let tap = if options.formula.reads_tap() {
434            branch.calc_divisible_tap(source_row)?
435        } else {
436            1.0
437        };
438        let branch_b = options
439            .formula
440            .calc_solver_edge_weight(branch.r, branch.x, tap)
441            * b_scale;
442        if !branch_b.is_finite() {
443            return Err(powerio_tx::Error::NonFiniteSusceptance { row: source_row }.into());
444        }
445        let shift_rad = if options.formula.includes_phase_shifts() {
446            case.to_radians(branch.shift)
447        } else {
448            0.0
449        };
450        if shift_rad != 0.0 {
451            p_shift[from] -= branch_b * shift_rad;
452            p_shift[to] += branch_b * shift_rad;
453        }
454        let source_amin = case.to_radians(branch.angmin);
455        let source_amax = case.to_radians(branch.angmax);
456        let (amin, amax) = if options.correct_angle_difference_bounds {
457            powerio_tx::correct_angle_difference_bounds(source_amin, source_amax)
458        } else {
459            (source_amin, source_amax)
460        };
461        from_bus.push(from);
462        branch_identities.push(crate::opf::row_identity(
463            branch.uid.as_deref(),
464            "branches",
465            source_row,
466        ));
467        to_bus.push(to);
468        b.push(branch_b);
469        shift.push(shift_rad);
470        f_max.push(thermal.of(
471            branch,
472            source_amin,
473            source_amax,
474            &buses[from_analysis],
475            &buses[to_analysis],
476        ));
477        angle_min.push(amin);
478        angle_max.push(amax);
479        branch_rows.push(source_row);
480    }
481
482    let n_active_generators = q.len();
483    let n_active_branches = b.len();
484    let bus_analysis_rows = active_buses.analysis_rows;
485    let p_d = bus_analysis_rows
486        .iter()
487        .map(|&row| case.pd()[row] * p_scale)
488        .collect();
489    let g_s = bus_analysis_rows
490        .iter()
491        .map(|&row| case.gs()[row] * p_scale)
492        .collect();
493    let bus_source_rows = bus_analysis_rows.iter().copied().map(Some).collect();
494    Ok(DcOpfPreparation {
495        name: case.name().to_owned(),
496        n_buses,
497        n_source_generators: case.generators().len(),
498        n_source_branches: case.branches().len(),
499        base_mva: case.base_mva(),
500        units: options.units,
501        formula: options.formula,
502        objective: options.objective,
503        skip_zero_impedance: options.skip_zero_impedance,
504        synthesize_unrated_limits: options.synthesize_unrated_limits,
505        correct_angle_difference_bounds: options.correct_angle_difference_bounds,
506        bus_ids: active_buses.bus_ids,
507        bus_analysis_rows,
508        bus_source_rows,
509        reference_buses: active_buses.reference_buses,
510        p_d,
511        g_s,
512        p_shift,
513        generators: DcGeneratorParameters {
514            identities: generator_identities,
515            bus_of_gen,
516            analysis_rows: generator_rows.clone(),
517            source_rows: generator_rows.into_iter().map(Some).collect(),
518            q,
519            c,
520            c0,
521            piecewise_linear,
522            pmax,
523            pmin,
524            capability_active: vec![true; n_active_generators],
525        },
526        branches: DcBranchParameters {
527            identities: branch_identities,
528            from_bus,
529            to_bus,
530            susceptance_magnitude: b,
531            shift,
532            f_max,
533            angle_min,
534            angle_max,
535            analysis_rows: branch_rows.clone(),
536            analysis_sources: branch_rows
537                .iter()
538                .copied()
539                .map(|row| AnalysisBranchSource::Branch { row })
540                .collect(),
541            skipped_zero_impedance,
542            thermal_limit_active: vec![true; n_active_branches],
543            angle_bound_active: vec![true; n_active_branches],
544        },
545    })
546}
547
548/// Apply the source instance's active constraint selections and source row
549/// provenance after the numerical view has been star-lowered.
550pub(crate) fn apply_instance_semantics(
551    preparation: &mut DcOpfPreparation,
552    source: &BalancedNetwork,
553    constraints: &powerio_prob::ActiveConstraints,
554) -> Result<()> {
555    let source_generator_ids: Vec<String> = source
556        .generators()
557        .iter()
558        .enumerate()
559        .map(|(row, generator)| {
560            crate::opf::row_identity(generator.uid.as_deref(), "generators", row)
561        })
562        .collect();
563    let source_branch_ids: Vec<String> = source
564        .branches()
565        .iter()
566        .enumerate()
567        .map(|(row, branch)| crate::opf::row_identity(branch.uid.as_deref(), "branches", row))
568        .collect();
569
570    preparation.generators.capability_active = crate::opf::constraint_mask(
571        "generator capability",
572        &constraints.generator_capability,
573        &source_generator_ids,
574        &preparation.generators.identities,
575    )?;
576
577    // DC fixes every voltage magnitude at one per unit, so it has no voltage
578    // bound rows to expose. Still validate an explicit identity selection:
579    // a misspelled bus must not disappear merely because this formulation
580    // has no corresponding variable.
581    let source_bus_ids: Vec<String> = source
582        .buses()
583        .iter()
584        .map(|bus| bus.id.to_string())
585        .collect();
586    let _ = crate::opf::constraint_mask(
587        "bus voltage bounds",
588        &constraints.voltage_bounds,
589        &source_bus_ids,
590        &[],
591    )?;
592
593    // Synthetic winding branches are part of the analysis family and are
594    // addressable by the identities returned in the preparation.
595    let mut analysis_branch_ids = source_branch_ids;
596    analysis_branch_ids.extend(
597        preparation
598            .branches
599            .identities
600            .iter()
601            .zip(&preparation.branches.analysis_rows)
602            .filter(|(_, row)| **row >= source.branches().len())
603            .map(|(identity, _)| identity.clone()),
604    );
605    preparation.branches.thermal_limit_active = crate::opf::constraint_mask(
606        "branch thermal limits",
607        &constraints.thermal_limits,
608        &analysis_branch_ids,
609        &preparation.branches.identities,
610    )?;
611    for (active, limit) in preparation
612        .branches
613        .thermal_limit_active
614        .iter_mut()
615        .zip(&preparation.branches.f_max)
616    {
617        *active &= *limit > 0.0;
618    }
619    preparation.branches.angle_bound_active = crate::opf::constraint_mask(
620        "branch angle bounds",
621        &constraints.angle_bounds,
622        &analysis_branch_ids,
623        &preparation.branches.identities,
624    )?;
625
626    preparation.n_source_generators = source.generators().len();
627    preparation.n_source_branches = source.branches().len();
628    preparation.bus_source_rows = preparation
629        .bus_analysis_rows
630        .iter()
631        .map(|&row| (row < source.buses().len()).then_some(row))
632        .collect();
633    preparation.generators.source_rows = preparation
634        .generators
635        .analysis_rows
636        .iter()
637        .map(|&row| (row < source.generators().len()).then_some(row))
638        .collect();
639    let analysis_sources = crate::opf::analysis_branch_sources(source);
640    preparation.branches.analysis_sources = preparation
641        .branches
642        .analysis_rows
643        .iter()
644        .map(|&row| analysis_sources[row])
645        .collect();
646    Ok(())
647}