Skip to main content

powerio_dist/
lindist3flow.rs

1//! Audited, format-neutral network preparation for LinDist3Flow.
2//!
3//! The input network is never mutated. The returned network contains only
4//! transformations selected by the caller's policy, and every material
5//! transformation or omission has one typed action in the report.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::{
13    Configuration, DistCapacitor, DistGenerator, DistIbr, DistLine, DistLineCode,
14    DistLoadVoltageModel, DistShunt, Error, IbrPrimeMover, IbrTopology, MulticonductorNetwork,
15    NeutralKronOptions, NeutralKronReport, Result, neutral_kron_reduce,
16};
17
18/// How aggressively the distribution network may be prepared for the
19/// LinDist3Flow formulation.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum LinDist3FlowPreparationPolicy {
25    /// Do not transform unsupported data.
26    #[default]
27    Reject,
28    /// Apply representation-preserving static lowerings.
29    Lower,
30    /// Additionally apply named affine/static approximations.
31    Approximate,
32    /// Additionally omit typed or retained data that has no formulation role.
33    Permissive,
34}
35
36impl LinDist3FlowPreparationPolicy {
37    const fn level(self) -> u8 {
38        match self {
39            Self::Reject => 0,
40            Self::Lower => 1,
41            Self::Approximate => 2,
42            Self::Permissive => 3,
43        }
44    }
45
46    const fn at_least(self, required: Self) -> bool {
47        self.level() >= required.level()
48    }
49
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Reject => "reject",
54            Self::Lower => "lower",
55            Self::Approximate => "approximate",
56            Self::Permissive => "permissive",
57        }
58    }
59}
60
61/// One auditable preparation decision.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum LinDist3FlowPreparationActionKind {
67    NeutralKronReduced,
68    OpenSwitchOmitted,
69    ClosedSwitchLowered,
70    LineShuntLowered,
71    CapacitorLowered,
72    LoadApproximated,
73    IbrApproximated,
74    StaticControlFrozen,
75    UntypedObjectOmitted,
76    InitialPointOmitted,
77}
78
79impl LinDist3FlowPreparationActionKind {
80    #[must_use]
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::NeutralKronReduced => "neutral_kron_reduced",
84            Self::OpenSwitchOmitted => "open_switch_omitted",
85            Self::ClosedSwitchLowered => "closed_switch_lowered",
86            Self::LineShuntLowered => "line_shunt_lowered",
87            Self::CapacitorLowered => "capacitor_lowered",
88            Self::LoadApproximated => "load_approximated",
89            Self::IbrApproximated => "ibr_approximated",
90            Self::StaticControlFrozen => "static_control_frozen",
91            Self::UntypedObjectOmitted => "untyped_object_omitted",
92            Self::InitialPointOmitted => "initial_point_omitted",
93        }
94    }
95
96    /// Whether this action changes component behavior rather than only its
97    /// representation.
98    #[must_use]
99    pub const fn is_approximation(self) -> bool {
100        matches!(
101            self,
102            Self::LoadApproximated
103                | Self::IbrApproximated
104                | Self::StaticControlFrozen
105                | Self::UntypedObjectOmitted
106                | Self::InitialPointOmitted
107        )
108    }
109
110    /// Whether the source record is intentionally absent from the prepared
111    /// formulation network.
112    #[must_use]
113    pub const fn is_omission(self) -> bool {
114        matches!(
115            self,
116            Self::OpenSwitchOmitted
117                | Self::StaticControlFrozen
118                | Self::UntypedObjectOmitted
119                | Self::InitialPointOmitted
120        )
121    }
122}
123
124/// One source component and the prepared representation chosen for it.
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[non_exhaustive]
128pub struct LinDist3FlowPreparationAction {
129    pub kind: LinDist3FlowPreparationActionKind,
130    pub source: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub target: Option<String>,
133    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134    pub details: BTreeMap<String, Value>,
135}
136
137impl LinDist3FlowPreparationAction {
138    /// Create one typed action. Callers may add machine-readable `details`
139    /// before appending it to a report.
140    #[must_use]
141    pub fn new(
142        kind: LinDist3FlowPreparationActionKind,
143        source: impl Into<String>,
144        target: Option<String>,
145    ) -> Self {
146        Self {
147            kind,
148            source: source.into(),
149            target,
150            details: BTreeMap::new(),
151        }
152    }
153}
154
155/// Complete provenance for a LinDist3Flow preparation pass.
156#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
157#[non_exhaustive]
158pub struct LinDist3FlowPreparationReport {
159    pub policy: LinDist3FlowPreparationPolicy,
160    pub actions: Vec<LinDist3FlowPreparationAction>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub neutral_kron: Option<NeutralKronReport>,
163}
164
165/// Independently owned prepared network and its audit report.
166#[derive(Clone, Debug)]
167pub struct LinDist3FlowPreparedNetwork {
168    network: MulticonductorNetwork,
169    report: LinDist3FlowPreparationReport,
170}
171
172impl LinDist3FlowPreparedNetwork {
173    #[must_use]
174    pub fn network(&self) -> &MulticonductorNetwork {
175        &self.network
176    }
177
178    #[must_use]
179    pub const fn report(&self) -> &LinDist3FlowPreparationReport {
180        &self.report
181    }
182
183    #[must_use]
184    pub fn into_parts(self) -> (MulticonductorNetwork, LinDist3FlowPreparationReport) {
185        (self.network, self.report)
186    }
187}
188
189fn fail(message: impl Into<String>) -> Error {
190    Error::LinDist3FlowPreparation {
191        message: message.into(),
192    }
193}
194
195fn unique_name(prefix: &str, names: &mut BTreeSet<String>) -> String {
196    let mut candidate = prefix.to_owned();
197    let mut suffix = 2usize;
198    while !names.insert(candidate.to_ascii_lowercase()) {
199        candidate = format!("{prefix}-{suffix}");
200        suffix += 1;
201    }
202    candidate
203}
204
205fn zero_matrix(n: usize) -> Vec<Vec<f64>> {
206    vec![vec![0.0; n]; n]
207}
208
209fn scaled(matrix: &[Vec<f64>], scale: f64) -> Vec<Vec<f64>> {
210    matrix
211        .iter()
212        .map(|row| row.iter().map(|value| value * scale).collect())
213        .collect()
214}
215
216fn nonzero(matrix: &[Vec<f64>]) -> bool {
217    matrix.iter().flatten().any(|value| *value != 0.0)
218}
219
220fn lower_line_shunts(
221    network: &mut MulticonductorNetwork,
222    report: &mut LinDist3FlowPreparationReport,
223) -> Result<()> {
224    let lines = network.lines().clone();
225    let mut names = network
226        .shunts()
227        .iter()
228        .map(|shunt| shunt.name.to_ascii_lowercase())
229        .collect::<BTreeSet<_>>();
230    let mut additions = Vec::new();
231    for line in &lines {
232        let code = network.linecode(&line.linecode).ok_or_else(|| {
233            fail(format!(
234                "line `{}` names missing linecode `{}`",
235                line.name, line.linecode
236            ))
237        })?;
238        let n = line.terminal_map_from.len();
239        if line.terminal_map_to.len() != n
240            || [&code.g_from, &code.b_from, &code.g_to, &code.b_to]
241                .into_iter()
242                .any(|matrix| matrix.len() != n || matrix.iter().any(|row| row.len() != n))
243        {
244            return Err(fail(format!(
245                "line `{}` shunt matrices do not align with its terminal maps",
246                line.name
247            )));
248        }
249        for (side, bus, terminals, g, b) in [
250            (
251                "from",
252                &line.bus_from,
253                &line.terminal_map_from,
254                &code.g_from,
255                &code.b_from,
256            ),
257            (
258                "to",
259                &line.bus_to,
260                &line.terminal_map_to,
261                &code.g_to,
262                &code.b_to,
263            ),
264        ] {
265            if !nonzero(g) && !nonzero(b) {
266                continue;
267            }
268            let name = unique_name(
269                &format!("__l3f-line-{}-{side}-shunt", line.name),
270                &mut names,
271            );
272            additions.push(DistShunt::new(
273                &name,
274                bus,
275                terminals.clone(),
276                scaled(g, line.length),
277                scaled(b, line.length),
278            ));
279            report.actions.push(LinDist3FlowPreparationAction::new(
280                LinDist3FlowPreparationActionKind::LineShuntLowered,
281                format!("line.{}", line.name),
282                Some(format!("shunt.{name}")),
283            ));
284        }
285    }
286    network.shunts_mut().extend(additions);
287    for code in network.line_codes_mut() {
288        code.g_from = zero_matrix(code.n_conductors);
289        code.b_from = zero_matrix(code.n_conductors);
290        code.g_to = zero_matrix(code.n_conductors);
291        code.b_to = zero_matrix(code.n_conductors);
292    }
293    Ok(())
294}
295
296fn lower_switches(
297    network: &mut MulticonductorNetwork,
298    report: &mut LinDist3FlowPreparationReport,
299) -> Result<()> {
300    let switches = std::mem::take(network.switches_mut());
301    let mut line_names = network
302        .lines()
303        .iter()
304        .map(|line| line.name.to_ascii_lowercase())
305        .collect::<BTreeSet<_>>();
306    let mut code_names = network
307        .line_codes()
308        .iter()
309        .map(|code| code.name.to_ascii_lowercase())
310        .collect::<BTreeSet<_>>();
311    for switch in switches {
312        let source = format!("switch.{}", switch.name);
313        if switch.open {
314            report.actions.push(LinDist3FlowPreparationAction::new(
315                LinDist3FlowPreparationActionKind::OpenSwitchOmitted,
316                source,
317                None,
318            ));
319            continue;
320        }
321        let n = switch.terminal_map_from.len();
322        if n == 0 || switch.terminal_map_to.len() != n {
323            return Err(fail(format!(
324                "closed switch `{}` has empty or unequal terminal maps",
325                switch.name
326            )));
327        }
328        if switch
329            .i_max
330            .as_ref()
331            .is_some_and(|limits| limits.len() != n)
332        {
333            return Err(fail(format!(
334                "closed switch `{}` current limits do not align with its terminal maps",
335                switch.name
336            )));
337        }
338        let line_name = unique_name(&format!("__l3f-switch-{}", switch.name), &mut line_names);
339        let code_name = unique_name(
340            &format!("__l3f-switch-{}-ideal", switch.name),
341            &mut code_names,
342        );
343        network.line_codes_mut().push(DistLineCode::new(
344            &code_name,
345            zero_matrix(n),
346            zero_matrix(n),
347        ));
348        let mut line = DistLine::new(
349            &line_name,
350            switch.bus_from,
351            switch.bus_to,
352            switch.terminal_map_from,
353            switch.terminal_map_to,
354            code_name,
355            1.0,
356        );
357        line.i_max = switch.i_max;
358        network.lines_mut().push(line);
359        let mut action = LinDist3FlowPreparationAction::new(
360            LinDist3FlowPreparationActionKind::ClosedSwitchLowered,
361            source,
362            Some(format!("line.{line_name}")),
363        );
364        action.details.insert(
365            "flow_identity".to_owned(),
366            json!("independent_contact_flow"),
367        );
368        report.actions.push(action);
369    }
370    Ok(())
371}
372
373fn capacitor_matrix(capacitor: &DistCapacitor) -> Result<Vec<Vec<f64>>> {
374    if !capacitor.q_rated.is_finite()
375        || !capacitor.v_nom.is_finite()
376        || capacitor.v_nom <= 0.0
377        || capacitor.terminal_map.is_empty()
378    {
379        return Err(fail(format!(
380            "capacitor `{}` has invalid rating, nominal voltage, or terminal map",
381            capacitor.name
382        )));
383    }
384    let n = capacitor.terminal_map.len();
385    let mut matrix = zero_matrix(n);
386    let mut couple = |a: usize, b: usize, value: f64| {
387        matrix[a][a] += value;
388        matrix[b][b] += value;
389        matrix[a][b] -= value;
390        matrix[b][a] -= value;
391    };
392    match capacitor.configuration {
393        // A lone wye terminal is a phase-to-ground unit rated across itself.
394        Configuration::Wye if n == 1 => {
395            matrix[0][0] = capacitor.q_rated / capacitor.v_nom.powi(2);
396        }
397        // Phase terminals to the last (neutral) terminal at line-to-line
398        // nameplate voltage, matching the Y-bus and PMD conventions.
399        Configuration::Wye => {
400            let phases = n - 1;
401            let v_ln = capacitor.v_nom / 3f64.sqrt();
402            #[allow(clippy::cast_precision_loss)]
403            let value = capacitor.q_rated / (phases as f64) / v_ln.powi(2);
404            for phase in 0..phases {
405                couple(phase, phases, value);
406            }
407        }
408        Configuration::SinglePhase if n == 2 => {
409            couple(0, 1, capacitor.q_rated / capacitor.v_nom.powi(2));
410        }
411        Configuration::Delta if n == 3 => {
412            let value = capacitor.q_rated / (3.0 * capacitor.v_nom.powi(2));
413            for pair in 0..3 {
414                couple(pair, (pair + 1) % 3, value);
415            }
416        }
417        _ => {
418            return Err(fail(format!(
419                "capacitor `{}` {:?} connection with {n} terminals is not supported",
420                capacitor.name, capacitor.configuration
421            )));
422        }
423    }
424    Ok(matrix)
425}
426
427fn lower_capacitors(
428    network: &mut MulticonductorNetwork,
429    report: &mut LinDist3FlowPreparationReport,
430) -> Result<()> {
431    let capacitors = std::mem::take(network.capacitors_mut());
432    let mut names = network
433        .shunts()
434        .iter()
435        .map(|shunt| shunt.name.to_ascii_lowercase())
436        .collect::<BTreeSet<_>>();
437    for capacitor in capacitors {
438        let matrix = capacitor_matrix(&capacitor)?;
439        let name = unique_name(&format!("__l3f-capacitor-{}", capacitor.name), &mut names);
440        network.shunts_mut().push(DistShunt::new(
441            &name,
442            &capacitor.bus,
443            capacitor.terminal_map.clone(),
444            zero_matrix(matrix.len()),
445            matrix,
446        ));
447        report.actions.push(LinDist3FlowPreparationAction::new(
448            LinDist3FlowPreparationActionKind::CapacitorLowered,
449            format!("capacitor.{}", capacitor.name),
450            Some(format!("shunt.{name}")),
451        ));
452    }
453    Ok(())
454}
455
456fn approximate_loads(
457    network: &mut MulticonductorNetwork,
458    report: &mut LinDist3FlowPreparationReport,
459) -> Result<()> {
460    let expand = |values: &[f64], channels: usize, label: &str| -> Result<Vec<f64>> {
461        match values.len() {
462            1 => Ok(vec![values[0]; channels]),
463            length if length == channels => Ok(values.to_vec()),
464            length => Err(fail(format!(
465                "{label} has length {length}, expected 1 or {channels}"
466            ))),
467        }
468    };
469    for load in network.loads_mut() {
470        let approximation = match &load.voltage_model {
471            DistLoadVoltageModel::ConstantCurrent { v_nom } => Some(DistLoadVoltageModel::Zip {
472                v_nom: v_nom.clone(),
473                alpha_z: vec![0.5; load.p_nom.len()],
474                alpha_i: vec![0.0; load.p_nom.len()],
475                alpha_p: vec![0.5; load.p_nom.len()],
476                beta_z: vec![0.5; load.q_nom.len()],
477                beta_i: vec![0.0; load.q_nom.len()],
478                beta_p: vec![0.5; load.q_nom.len()],
479            }),
480            DistLoadVoltageModel::Zip {
481                v_nom,
482                alpha_z,
483                alpha_i,
484                alpha_p,
485                beta_z,
486                beta_i,
487                beta_p,
488            } if alpha_i.iter().any(|value| value.abs() > f64::EPSILON)
489                || beta_i.iter().any(|value| value.abs() > f64::EPSILON) =>
490            {
491                let combine = |z: &[f64],
492                               i: &[f64],
493                               p: &[f64],
494                               channels: usize|
495                 -> Result<(Vec<f64>, Vec<f64>)> {
496                    let z = expand(z, channels, "ZIP impedance fraction")?;
497                    let i = expand(i, channels, "ZIP current fraction")?;
498                    let p = expand(p, channels, "ZIP power fraction")?;
499                    Ok((
500                        z.iter().zip(&i).map(|(z, i)| z + 0.5 * i).collect(),
501                        p.iter().zip(&i).map(|(p, i)| p + 0.5 * i).collect(),
502                    ))
503                };
504                let (alpha_z, alpha_p) = combine(alpha_z, alpha_i, alpha_p, load.p_nom.len())?;
505                let (beta_z, beta_p) = combine(beta_z, beta_i, beta_p, load.q_nom.len())?;
506                Some(DistLoadVoltageModel::Zip {
507                    v_nom: v_nom.clone(),
508                    alpha_z,
509                    alpha_i: vec![0.0; load.p_nom.len()],
510                    alpha_p,
511                    beta_z,
512                    beta_i: vec![0.0; load.q_nom.len()],
513                    beta_p,
514                })
515            }
516            DistLoadVoltageModel::Exponential {
517                v_nom,
518                gamma_p,
519                gamma_q,
520            } => {
521                let gamma_p = expand(gamma_p, load.p_nom.len(), "active exponent")?;
522                let gamma_q = expand(gamma_q, load.q_nom.len(), "reactive exponent")?;
523                Some(DistLoadVoltageModel::Zip {
524                    v_nom: v_nom.clone(),
525                    alpha_z: gamma_p.iter().map(|gamma| 0.5 * gamma).collect(),
526                    alpha_i: vec![0.0; load.p_nom.len()],
527                    alpha_p: gamma_p.iter().map(|gamma| 1.0 - 0.5 * gamma).collect(),
528                    beta_z: gamma_q.iter().map(|gamma| 0.5 * gamma).collect(),
529                    beta_i: vec![0.0; load.q_nom.len()],
530                    beta_p: gamma_q.iter().map(|gamma| 1.0 - 0.5 * gamma).collect(),
531                })
532            }
533            _ => None,
534        };
535        if let Some(approximation) = approximation {
536            load.voltage_model = approximation;
537            let mut action = LinDist3FlowPreparationAction::new(
538                LinDist3FlowPreparationActionKind::LoadApproximated,
539                format!("load.{}", load.name),
540                Some(format!("load.{}", load.name)),
541            );
542            action.details.insert(
543                "method".to_owned(),
544                json!("first_order_squared_voltage_at_nominal"),
545            );
546            report.actions.push(action);
547        }
548    }
549    Ok(())
550}
551
552fn ibr_configuration(ibr: &DistIbr) -> Result<Configuration> {
553    match (ibr.topology, ibr.terminal_map.len(), ibr.s_max.len()) {
554        (IbrTopology::SinglePhase, 1, 1) => Ok(Configuration::Wye),
555        (IbrTopology::SinglePhase, 2, 1) => Ok(Configuration::SinglePhase),
556        (IbrTopology::ThreeLeg | IbrTopology::FourLeg, terminals, phases)
557            if terminals == phases =>
558        {
559            Ok(Configuration::Wye)
560        }
561        _ => Err(fail(format!(
562            "IBR `{}` topology and terminal/rating dimensions cannot be mapped to a static generator",
563            ibr.name
564        ))),
565    }
566}
567
568fn approximate_ibrs(
569    network: &mut MulticonductorNetwork,
570    report: &mut LinDist3FlowPreparationReport,
571) -> Result<()> {
572    let ibrs = std::mem::take(network.ibrs_mut());
573    let mut names = network
574        .generators()
575        .iter()
576        .map(|generator| generator.name.to_ascii_lowercase())
577        .collect::<BTreeSet<_>>();
578    for ibr in ibrs {
579        let channels = ibr.s_max.len();
580        if channels == 0
581            || ibr
582                .s_max
583                .iter()
584                .any(|rating| !rating.is_finite() || *rating <= 0.0)
585        {
586            return Err(fail(format!(
587                "IBR `{}` has invalid phase ratings",
588                ibr.name
589            )));
590        }
591        let configuration = ibr_configuration(&ibr)?;
592        let name = unique_name(&format!("__l3f-ibr-{}", ibr.name), &mut names);
593        let total_rating = ibr.s_max.iter().sum::<f64>();
594        let p_nom = ibr.p_avail.map_or_else(
595            || vec![0.0; channels],
596            |available| {
597                ibr.s_max
598                    .iter()
599                    .map(|rating| available * rating / total_rating)
600                    .collect()
601            },
602        );
603        let mut generator = DistGenerator::new(
604            &name,
605            &ibr.bus,
606            ibr.terminal_map.clone(),
607            configuration,
608            p_nom.clone(),
609            vec![0.0; channels],
610        );
611        generator.p_min = ibr.p_min.clone().or_else(|| {
612            (ibr.p_avail.is_some()
613                && matches!(ibr.prime_mover, IbrPrimeMover::Pv | IbrPrimeMover::Generic))
614            .then(|| vec![0.0; channels])
615        });
616        generator.p_max = ibr.p_max.clone().or_else(|| ibr.p_avail.map(|_| p_nom));
617        if generator.p_min.is_some() != generator.p_max.is_some() {
618            return Err(fail(format!(
619                "IBR `{}` supplies only one side of its active-power bounds",
620                ibr.name
621            )));
622        }
623        generator.q_min.clone_from(&ibr.q_min);
624        generator.q_max.clone_from(&ibr.q_max);
625        if generator.q_min.is_some() != generator.q_max.is_some() {
626            return Err(fail(format!(
627                "IBR `{}` supplies only one side of its reactive-power bounds",
628                ibr.name
629            )));
630        }
631        generator.s_max = Some(ibr.s_max.clone());
632        generator.i_max.clone_from(&ibr.i_max);
633        network.generators_mut().push(generator);
634        let mut action = LinDist3FlowPreparationAction::new(
635            LinDist3FlowPreparationActionKind::IbrApproximated,
636            format!("ibr.{}", ibr.name),
637            Some(format!("generator.{name}")),
638        );
639        action
640            .details
641            .insert("method".to_owned(), json!("static_dispatchable_generator"));
642        action
643            .details
644            .insert("control_profile".to_owned(), json!(&ibr.control_profile));
645        report.actions.push(action);
646        if let Some(profile) = &ibr.control_profile {
647            report.actions.push(LinDist3FlowPreparationAction::new(
648                LinDist3FlowPreparationActionKind::StaticControlFrozen,
649                format!("control_profile.{profile}"),
650                Some(format!("generator.{name}")),
651            ));
652        }
653    }
654    Ok(())
655}
656
657fn omit_controls_and_untyped(
658    network: &mut MulticonductorNetwork,
659    policy: LinDist3FlowPreparationPolicy,
660    report: &mut LinDist3FlowPreparationReport,
661) {
662    let objects = std::mem::take(network.untyped_objects_mut());
663    let mut retained = Vec::new();
664    for object in objects {
665        let control = matches!(
666            object.class.to_ascii_lowercase().as_str(),
667            "regcontrol" | "capcontrol" | "invcontrol" | "swtcontrol"
668        );
669        if control && policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
670            report.actions.push(LinDist3FlowPreparationAction::new(
671                LinDist3FlowPreparationActionKind::StaticControlFrozen,
672                format!("{}.{}", object.class, object.name),
673                None,
674            ));
675        } else if policy.at_least(LinDist3FlowPreparationPolicy::Permissive) {
676            report.actions.push(LinDist3FlowPreparationAction::new(
677                LinDist3FlowPreparationActionKind::UntypedObjectOmitted,
678                format!("{}.{}", object.class, object.name),
679                None,
680            ));
681        } else {
682            retained.push(object);
683        }
684    }
685    *network.untyped_objects_mut() = retained;
686    if policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
687        for profile in network.control_profiles() {
688            let source = format!("control_profile.{}", profile.name);
689            if !report.actions.iter().any(|action| action.source == source) {
690                report.actions.push(LinDist3FlowPreparationAction::new(
691                    LinDist3FlowPreparationActionKind::StaticControlFrozen,
692                    source,
693                    None,
694                ));
695            }
696        }
697        network.control_profiles_mut().clear();
698    }
699}
700
701/// Prepare an independently owned LinDist3Flow network according to `policy`.
702///
703/// The input remains unchanged. Static switch contacts become independent
704/// zero-impedance line-flow variables, avoiding any aggregation of parallel
705/// contact ratings. Line charging and capacitors become explicit terminal
706/// shunts. Current and exponential load behavior is linearized in squared
707/// voltage at nominal voltage. IBRs become static generators; their dynamic
708/// controls and storage state are not represented.
709///
710/// # Errors
711/// A selected transformation cannot preserve or explicitly approximate the
712/// component's dimensions, identities, bounds, or electrical parameters.
713pub fn prepare_lindist3flow_network(
714    network: &MulticonductorNetwork,
715    policy: LinDist3FlowPreparationPolicy,
716) -> Result<LinDist3FlowPreparedNetwork> {
717    let mut prepared = network.clone();
718    let mut report = LinDist3FlowPreparationReport {
719        policy,
720        ..LinDist3FlowPreparationReport::default()
721    };
722    if policy.at_least(LinDist3FlowPreparationPolicy::Lower) {
723        // Capacitors become typed shunts first so neutral reduction can
724        // eliminate a wye bank's explicit neutral terminal.
725        lower_capacitors(&mut prepared, &mut report)?;
726        let reduction = neutral_kron_reduce(&prepared, &NeutralKronOptions::default())?;
727        let (network, neutral_report) = reduction.into_parts();
728        prepared = network;
729        if !neutral_report.buses.is_empty() {
730            let mut action = LinDist3FlowPreparationAction::new(
731                LinDist3FlowPreparationActionKind::NeutralKronReduced,
732                "network",
733                Some("network".to_owned()),
734            );
735            action
736                .details
737                .insert("bus_count".to_owned(), json!(neutral_report.buses.len()));
738            report.actions.push(action);
739            report.neutral_kron = Some(neutral_report);
740        }
741        lower_switches(&mut prepared, &mut report)?;
742        lower_line_shunts(&mut prepared, &mut report)?;
743    }
744    if policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
745        approximate_loads(&mut prepared, &mut report)?;
746        approximate_ibrs(&mut prepared, &mut report)?;
747    }
748    omit_controls_and_untyped(&mut prepared, policy, &mut report);
749    prepared.extras_mut().insert(
750        "powerio_lindist3flow_preparation".to_owned(),
751        json!({
752            "policy": report.policy,
753            "actions": report.actions,
754        }),
755    );
756    Ok(LinDist3FlowPreparedNetwork {
757        network: prepared,
758        report,
759    })
760}