Skip to main content

powerio_prob/instance/
multiconductor.rs

1//! The multiconductor calculation instances: `McAcPfInstance` and
2//! `McAcOpfInstance`.
3//!
4//! Both share the reusable [`MulticonductorNetwork`] handle. The power flow
5//! instance contains the partial boundary specification a distribution power
6//! flow needs — prescribed terminal powers, prescribed source terminal
7//! voltages, isolated terminals, load voltage models, and the active
8//! equipment control modes — never a required complete operating point. The
9//! OPF instance adds the typed per phase objective and the active constraint
10//! selections.
11
12use powerio_core::Error;
13use powerio_dist::MulticonductorNetwork;
14use serde::{Deserialize, Serialize};
15
16use crate::OperatingPoint;
17use crate::diagnostics::codes;
18use crate::instance::balanced::transform_discarded;
19use crate::instance::constraints::MulticonductorActiveConstraints;
20use crate::instance::objective::{Objective, ObjectiveTerm};
21
22/// A source elsewhere in the document cannot anchor a disconnected island.
23/// This is a necessary bus-level check only: line coupling, winding maps and
24/// per-conductor voltage constraints still determine numerical solvability.
25fn require_source_covered_islands(network: &MulticonductorNetwork) -> Result<(), Error> {
26    let components = crate::update::multiconductor_network_connectivity(network);
27    let positions: std::collections::BTreeMap<_, _> = network
28        .buses()
29        .iter()
30        .enumerate()
31        .map(|(index, bus)| (bus.id.to_ascii_lowercase(), index))
32        .collect();
33    let mut covered = std::collections::BTreeSet::new();
34    for source in network.sources() {
35        if let Some(&index) = positions.get(&source.bus.to_ascii_lowercase()) {
36            covered.insert(components[index]);
37        }
38    }
39    for (index, bus) in network.buses().iter().enumerate() {
40        if !covered.contains(&components[index]) {
41            return Err(Error::new(
42                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
43                format!(
44                    "multiconductor island containing bus `{}` has no voltage source; explicitly resolve de-energized islands before constructing a calculation instance",
45                    bus.id
46                ),
47            ));
48        }
49    }
50    Ok(())
51}
52
53/// One load's prescribed terminal power: the load's stated per phase complex
54/// power, keyed by the load's name and its terminal map. Watts and vars, as
55/// the network states them.
56#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
57pub struct PrescribedTerminalPower {
58    pub load: String,
59    /// The load's terminals, in its stated terminal map order.
60    pub terminals: Vec<String>,
61    /// Watts per terminal, aligned to `terminals`.
62    pub p_w: Vec<f64>,
63    /// Vars per terminal, aligned to `terminals`.
64    pub q_var: Vec<f64>,
65    /// The load's stated voltage dependence.
66    pub voltage_model: powerio_dist::DistLoadVoltageModel,
67}
68
69/// One source's prescribed phasor parameters, keyed to its network source.
70/// This record does not carry the bus or voltage reference. Use
71/// [`McAcPfInstance::source_boundary`] for the complete borrowed boundary.
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct PrescribedSourceVoltage {
74    pub source: String,
75    /// The source's terminals, in its stated terminal map order.
76    pub terminals: Vec<String>,
77    /// Source voltage magnitudes in volts; their reference is on the network source.
78    pub v_magnitude: Vec<f64>,
79    /// Radians per terminal.
80    pub v_angle: Vec<f64>,
81}
82
83/// Complete borrowed source boundary: V(terminal) - V(reference) is the
84/// stated phasor. None means earth; a named reference is on `bus` and does
85/// not imply a connection to earth. Every phase current has an equal
86/// opposite contribution at the reference.
87#[derive(Clone, Copy, Debug)]
88#[non_exhaustive]
89pub struct PrescribedSourceBoundary<'a> {
90    pub source: &'a str,
91    pub bus: &'a str,
92    pub terminals: &'a [String],
93    pub reference_terminal: Option<&'a str>,
94    pub v_magnitude: &'a [f64],
95    pub v_angle: &'a [f64],
96}
97
98/// One equipment control that is active for the calculation, by element name.
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case", tag = "kind")]
101#[non_exhaustive]
102pub enum ActiveControlMode {
103    /// A regulating transformer's tap control.
104    RegulatorTap { transformer: String },
105    /// A switched capacitor's step control.
106    CapacitorSteps { capacitor: String },
107}
108
109/// The multiconductor AC power flow instance.
110#[derive(Clone, Debug)]
111pub struct McAcPfInstance {
112    network: MulticonductorNetwork,
113    loads: Vec<PrescribedTerminalPower>,
114    sources: Vec<PrescribedSourceVoltage>,
115    isolated_terminals: Vec<(String, String)>,
116    control_modes: Vec<ActiveControlMode>,
117    initial_point: Option<OperatingPoint<MulticonductorNetwork>>,
118}
119
120impl McAcPfInstance {
121    /// Build the instance from the network's stated data: every in service
122    /// load contributes its prescribed per phase powers and voltage model,
123    /// every voltage source its prescribed terminal complex voltage, and
124    /// every stated regulator and capacitor control an active control mode.
125    ///
126    /// # Errors
127    /// A network with no voltage source, or a bus island without a voltage
128    /// source. This necessary connectivity check does not establish conductor
129    /// energization or numerical solvability.
130    pub fn from_network(network: MulticonductorNetwork) -> Result<Self, Error> {
131        powerio_dist::require_electrical_readiness(&network)?;
132        if network.sources().is_empty() {
133            return Err(Error::new(
134                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
135                "the multiconductor network states no voltage source to anchor the calculation",
136            ));
137        }
138        require_source_covered_islands(&network)?;
139        let loads = network
140            .loads()
141            .iter()
142            .map(|load| PrescribedTerminalPower {
143                load: load.name.clone(),
144                terminals: load.terminal_map.clone(),
145                p_w: load.p_nom.clone(),
146                q_var: load.q_nom.clone(),
147                voltage_model: load.voltage_model.clone(),
148            })
149            .collect();
150        let sources = network
151            .sources()
152            .iter()
153            .map(|source| PrescribedSourceVoltage {
154                source: source.name.clone(),
155                terminals: source.terminal_map.clone(),
156                v_magnitude: source.v_magnitude.clone(),
157                v_angle: source.v_angle.clone(),
158            })
159            .collect();
160        // Controllers live in the retained untyped objects: a `regcontrol`
161        // regulates the transformer its `transformer` property names, and a
162        // `capcontrol` switches the capacitor its `capacitor` property names.
163        let controlled_element = |object: &powerio_dist::UntypedObject, key: &str| {
164            object
165                .props
166                .iter()
167                .find(|(name, _)| name.as_deref() == Some(key))
168                .map(|(_, value)| value.clone())
169        };
170        let mut control_modes = Vec::new();
171        for object in network.untyped_objects() {
172            match object.class.to_ascii_lowercase().as_str() {
173                "regcontrol" => {
174                    if let Some(transformer) = controlled_element(object, "transformer") {
175                        control_modes.push(ActiveControlMode::RegulatorTap { transformer });
176                    }
177                }
178                "capcontrol" => {
179                    if let Some(capacitor) = controlled_element(object, "capacitor") {
180                        control_modes.push(ActiveControlMode::CapacitorSteps { capacitor });
181                    }
182                }
183                _ => {}
184            }
185        }
186        Ok(Self {
187            network,
188            loads,
189            sources,
190            isolated_terminals: Vec::new(),
191            control_modes,
192            initial_point: None,
193        })
194    }
195
196    /// Supply an optional solver initial point.
197    #[must_use]
198    pub fn with_initial_point(mut self, point: OperatingPoint<MulticonductorNetwork>) -> Self {
199        self.initial_point = Some(point);
200        self
201    }
202
203    /// Replace the network and recalculate its prescribed powers, source
204    /// voltages, and active controls while preserving a compatible initial
205    /// point.
206    pub fn with_network(mut self, network: MulticonductorNetwork) -> Result<Self, Error> {
207        let mut replacement = Self::from_network(network.clone())?;
208        if let Some(initial) = self.initial_point.take() {
209            replacement.initial_point = Some(initial.rebind_network(network)?);
210        }
211        Ok(replacement)
212    }
213
214    /// The network this instance calculates on. Borrowed; never a copy.
215    #[must_use]
216    pub fn network(&self) -> &MulticonductorNetwork {
217        &self.network
218    }
219
220    /// The prescribed load terminal powers, in load table order.
221    #[must_use]
222    pub fn loads(&self) -> &[PrescribedTerminalPower] {
223        &self.loads
224    }
225
226    /// Source phasor parameters in source table order. Resolve each record
227    /// against the associated network source, or use [`Self::source_boundary`]
228    /// to obtain both endpoints with the phasors.
229    #[must_use]
230    pub fn sources(&self) -> &[PrescribedSourceVoltage] {
231        &self.sources
232    }
233
234    /// Both voltage endpoints and their phasor differences, borrowed from
235    /// this instance. No network cloning or serialization is involved.
236    pub fn source_boundary(&self, index: usize) -> Option<PrescribedSourceBoundary<'_>> {
237        let values = self.sources.get(index)?;
238        let source = self.network.sources().get(index)?;
239        Some(PrescribedSourceBoundary {
240            source: &values.source,
241            bus: &source.bus,
242            terminals: &values.terminals,
243            reference_terminal: source.reference_terminal.as_deref(),
244            v_magnitude: &values.v_magnitude,
245            v_angle: &values.v_angle,
246        })
247    }
248
249    /// Terminals with no equation, as `(bus, terminal)` pairs.
250    #[must_use]
251    pub fn isolated_terminals(&self) -> &[(String, String)] {
252        &self.isolated_terminals
253    }
254
255    /// The equipment controls active for the calculation.
256    #[must_use]
257    pub fn control_modes(&self) -> &[ActiveControlMode] {
258        &self.control_modes
259    }
260
261    /// The optional solver initial point.
262    #[must_use]
263    pub const fn initial_point(&self) -> Option<&OperatingPoint<MulticonductorNetwork>> {
264        self.initial_point.as_ref()
265    }
266}
267
268/// The multiconductor AC optimal power flow instance: the shared network,
269/// the typed per phase objective, and the active terminal voltage, conductor,
270/// and per phase generator constraint selections.
271#[derive(Clone, Debug)]
272pub struct McAcOpfInstance {
273    network: MulticonductorNetwork,
274    objective: Objective,
275    constraints: MulticonductorActiveConstraints,
276    initial_point: Option<OperatingPoint<MulticonductorNetwork>>,
277}
278
279impl McAcOpfInstance {
280    /// Build the instance with the default per phase objective and every
281    /// stated limit active.
282    ///
283    /// # Errors
284    /// A network with no voltage source, or a bus island without one.
285    pub fn from_network(network: MulticonductorNetwork) -> Result<Self, Error> {
286        powerio_dist::require_electrical_readiness(&network)?;
287        if network.sources().is_empty() {
288            return Err(Error::new(
289                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
290                "the multiconductor network states no voltage source to anchor the calculation",
291            ));
292        }
293        require_source_covered_islands(&network)?;
294        Ok(Self {
295            network,
296            objective: Objective::active_power_dispatch_cost(),
297            constraints: MulticonductorActiveConstraints::default(),
298            initial_point: None,
299        })
300    }
301
302    /// Replace the objective, consuming the instance. The shared network
303    /// moves; no table is copied.
304    #[must_use]
305    pub fn with_objective(mut self, objective: Objective) -> Self {
306        self.objective = objective;
307        self
308    }
309
310    /// Append one objective term, consuming the instance.
311    #[must_use]
312    pub fn with_objective_term(mut self, term: ObjectiveTerm) -> Self {
313        self.objective = std::mem::take(&mut self.objective).with_term(term);
314        self
315    }
316
317    /// Replace the active constraint selections, consuming the instance.
318    #[must_use]
319    pub fn with_constraints(mut self, constraints: MulticonductorActiveConstraints) -> Self {
320        self.constraints = constraints;
321        self
322    }
323
324    /// Supply an optional solver initial point.
325    #[must_use]
326    pub fn with_initial_point(mut self, point: OperatingPoint<MulticonductorNetwork>) -> Self {
327        self.initial_point = Some(point);
328        self
329    }
330
331    /// Replace the network while preserving this instance's objective,
332    /// constraint selections, and a compatible initial point.
333    pub fn with_network(mut self, network: MulticonductorNetwork) -> Result<Self, Error> {
334        powerio_dist::require_electrical_readiness(&network)?;
335        if network.sources().is_empty() {
336            return Err(Error::new(
337                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
338                "the multiconductor network states no voltage source to anchor the calculation",
339            ));
340        }
341        require_source_covered_islands(&network)?;
342        if let Some(initial) = self.initial_point.take() {
343            self.initial_point = Some(initial.rebind_network(network.clone())?);
344        }
345        self.network = network;
346        Ok(self)
347    }
348
349    /// The network this instance calculates on. Borrowed; never a copy.
350    #[must_use]
351    pub fn network(&self) -> &MulticonductorNetwork {
352        &self.network
353    }
354
355    /// The typed objective.
356    #[must_use]
357    pub const fn objective(&self) -> &Objective {
358        &self.objective
359    }
360
361    /// The active constraint selections.
362    #[must_use]
363    pub const fn constraints(&self) -> &MulticonductorActiveConstraints {
364        &self.constraints
365    }
366
367    /// The optional solver initial point.
368    #[must_use]
369    pub const fn initial_point(&self) -> Option<&OperatingPoint<MulticonductorNetwork>> {
370        self.initial_point.as_ref()
371    }
372
373    /// The multiconductor power flow instance for this problem's network at
374    /// its stated injections: the objective and constraint selections are
375    /// discarded, and the discard is recorded.
376    ///
377    /// # Errors
378    /// As [`McAcPfInstance::from_network`].
379    pub fn to_mc_ac_pf(&self) -> Result<(McAcPfInstance, Vec<powerio_core::Diagnostic>), Error> {
380        let instance = McAcPfInstance::from_network(self.network.clone())?;
381        Ok((
382            instance,
383            vec![transform_discarded(
384                "the objective and the active constraint selections",
385            )],
386        ))
387    }
388}