Skip to main content

powerio_matrix/
opf.rs

1//! Shared OPF preparation semantics.
2
3use std::collections::HashSet;
4
5use powerio_prob::{ConstraintSelection, Objective, ObjectiveTerm, ReferenceBuses};
6use powerio_tx::{BalancedNetwork, BusId, BusType, IndexedNetwork};
7
8use crate::{Error, Result};
9
10/// The objective compiled into a balanced OPF preparation.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum PreparedObjective {
15    /// A feasibility problem with an identically zero objective.
16    Feasibility,
17    /// The sum of the in service generators' network cost curves.
18    #[default]
19    NetworkGeneratorCost,
20}
21
22/// One convex piecewise linear generator cost in preparation units.
23///
24/// `power[i]` and `value[i]` are the supplied breakpoint coordinates after
25/// scaling the power coordinate into the preparation's [`Units`](crate::Units).
26/// Objective values do not scale with the power unit. The preparation builder
27/// validates that both columns have the same length, power is strictly
28/// increasing, and adjacent segment slopes are nondecreasing.
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
30#[non_exhaustive]
31pub struct PiecewiseLinearCost {
32    /// Breakpoint powers in the preparation's selected power unit.
33    pub power: Vec<f64>,
34    /// Objective values at the corresponding breakpoints.
35    pub value: Vec<f64>,
36}
37
38/// The source component represented by one branch row in a lowered analysis
39/// network.
40///
41/// Ordinary branches map to their row in `BalancedNetwork::branches()`. Each
42/// in service three winding transformer contributes three analysis branches,
43/// one for each winding in the transformer's declared terminal order. Those
44/// rows stay distinct from the source branch table.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case", tag = "kind")]
47#[non_exhaustive]
48pub enum AnalysisBranchSource {
49    Branch {
50        row: usize,
51    },
52    ThreeWindingTransformerWinding {
53        transformer_row: usize,
54        /// Winding position in `0..3`.
55        winding: usize,
56    },
57}
58
59pub(crate) fn analysis_branch_sources(source: &BalancedNetwork) -> Vec<AnalysisBranchSource> {
60    let mut sources = (0..source.branches().len())
61        .map(|row| AnalysisBranchSource::Branch { row })
62        .collect::<Vec<_>>();
63    for (transformer_row, transformer) in source.transformers_3w().iter().enumerate() {
64        if !transformer.in_service {
65            continue;
66        }
67        sources.extend((0..3).map(|winding| {
68            AnalysisBranchSource::ThreeWindingTransformerWinding {
69                transformer_row,
70                winding,
71            }
72        }));
73    }
74    sources
75}
76
77pub(crate) fn compile_objective(objective: &Objective) -> Result<PreparedObjective> {
78    match objective.terms() {
79        [] => Ok(PreparedObjective::Feasibility),
80        [ObjectiveTerm::NetworkGeneratorCost] => Ok(PreparedObjective::NetworkGeneratorCost),
81        [ObjectiveTerm::ActivePowerDispatchCost] => Err(Error::UnsupportedOpfObjective {
82            reason: "`active_power_dispatch_cost` belongs to multiconductor OPF".to_owned(),
83        }),
84        _ => Err(Error::UnsupportedOpfObjective {
85            reason: "balanced OPF preparation supports either an empty objective or exactly one `network_generator_cost` term".to_owned(),
86        }),
87    }
88}
89
90pub(crate) fn row_identity(uid: Option<&str>, table: &str, row: usize) -> String {
91    uid.map_or_else(|| format!("{table}:{row}"), str::to_owned)
92}
93
94/// Dense bus rows used by a balanced OPF preparation. A bus explicitly typed
95/// isolated states no equation, so it and every incident element stay out of
96/// the numerical problem while its source row remains in the PowerIO model.
97pub(crate) struct ActiveBusIndex {
98    pub analysis_rows: Vec<usize>,
99    pub dense_by_analysis: Vec<Option<usize>>,
100    pub bus_ids: Vec<BusId>,
101    pub reference_buses: ReferenceBuses,
102}
103
104fn find(parent: &mut [usize], mut node: usize) -> usize {
105    while parent[node] != node {
106        parent[node] = parent[parent[node]];
107        node = parent[node];
108    }
109    node
110}
111
112/// Select non-isolated buses and check reference coverage on the topology that
113/// the OPF preparation will actually contain. In-service branches touching an
114/// isolated bus are excluded with that bus, matching PowerIO normalization.
115pub(crate) fn active_bus_index(case: &IndexedNetwork<'_>) -> Result<ActiveBusIndex> {
116    let mut analysis_rows = Vec::new();
117    let mut dense_by_analysis = vec![None; case.n()];
118    let mut bus_ids = Vec::new();
119    let mut reference_rows = Vec::new();
120    for (analysis_row, bus) in case.network().buses().iter().enumerate() {
121        if bus.kind == BusType::Isolated {
122            continue;
123        }
124        let dense = analysis_rows.len();
125        analysis_rows.push(analysis_row);
126        dense_by_analysis[analysis_row] = Some(dense);
127        bus_ids.push(bus.id);
128        if bus.kind == BusType::Ref {
129            reference_rows.push(dense);
130        }
131    }
132
133    let mut parent: Vec<usize> = (0..analysis_rows.len()).collect();
134    for (_, branch) in case.in_service_branches() {
135        let Some(from_analysis) = case.bus_index(branch.from) else {
136            continue;
137        };
138        let Some(to_analysis) = case.bus_index(branch.to) else {
139            continue;
140        };
141        let (Some(from), Some(to)) = (
142            dense_by_analysis[from_analysis],
143            dense_by_analysis[to_analysis],
144        ) else {
145            continue;
146        };
147        let from_root = find(&mut parent, from);
148        let to_root = find(&mut parent, to);
149        if from_root != to_root {
150            parent[to_root] = from_root;
151        }
152    }
153
154    let mut grounded = vec![false; parent.len()];
155    for &reference in &reference_rows {
156        let root = find(&mut parent, reference);
157        grounded[root] = true;
158    }
159    let mut roots = std::collections::HashSet::with_capacity(parent.len());
160    for bus in 0..parent.len() {
161        roots.insert(find(&mut parent, bus));
162    }
163    let ungrounded = roots.iter().filter(|&&root| !grounded[root]).count();
164    if ungrounded > 0 {
165        return Err(powerio_tx::Error::UngroundedComponent {
166            components: ungrounded,
167        }
168        .into());
169    }
170
171    Ok(ActiveBusIndex {
172        analysis_rows,
173        dense_by_analysis,
174        bus_ids,
175        reference_buses: ReferenceBuses::new(reference_rows),
176    })
177}
178
179/// Validate a selection against the complete family and return one flag per
180/// active analysis row.
181pub(crate) fn constraint_mask(
182    family: &'static str,
183    selection: &ConstraintSelection,
184    all_identities: &[String],
185    active_identities: &[String],
186) -> Result<Vec<bool>> {
187    let mut declared = HashSet::with_capacity(all_identities.len());
188    for identity in all_identities {
189        if !declared.insert(identity.as_str()) {
190            return Err(Error::DuplicateElementIdentity {
191                family,
192                identity: identity.clone(),
193            });
194        }
195    }
196    if let ConstraintSelection::Only(selected) = selection {
197        for identity in selected {
198            if !declared.contains(identity.as_str()) {
199                return Err(Error::UnknownConstraintIdentity {
200                    family,
201                    identity: identity.clone(),
202                });
203            }
204        }
205    }
206    Ok(active_identities
207        .iter()
208        .map(|identity| selection.selects(identity))
209        .collect())
210}