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