Skip to main content

powerio_prob/solution/
lindist3flow.rs

1//! Solver-independent LinDist3Flow OPF results.
2//!
3//! Values follow formulation identities and physical table order, never a
4//! solver adapter's column order. This lets a native or WASM solver return the
5//! same public solution type after translating its primal vector.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use powerio_core::Error;
11use powerio_dist::MulticonductorNetwork;
12
13use crate::diagnostics::codes;
14use crate::instance::{LinDist3FlowNode, LinDist3FlowOpfInstance};
15use crate::solution::{Producer, Residuals, Termination};
16
17/// Physical primal values from a LinDist3Flow solve.
18///
19/// Node values follow `instance.topology().nodes`. Line values follow
20/// `instance.topology().conductors`. Generator channels are flattened in
21/// generator table order, then channel order. Source channels are flattened
22/// in source table order, then terminal-map order. Voltage is squared volts;
23/// all power quantities are watts or vars.
24#[derive(Clone, Debug, Default, PartialEq)]
25#[non_exhaustive]
26pub struct LinDist3FlowOpfValues {
27    pub terminal_voltage_magnitude_squared: Vec<f64>,
28    pub line_active_power: Vec<f64>,
29    pub line_reactive_power: Vec<f64>,
30    pub generator_active_power: Vec<f64>,
31    pub generator_reactive_power: Vec<f64>,
32    pub source_active_power: Vec<f64>,
33    pub source_reactive_power: Vec<f64>,
34}
35
36fn shape(what: &str, actual: usize, expected: usize) -> Result<(), Error> {
37    if actual == expected {
38        Ok(())
39    } else {
40        Err(Error::new(
41            &codes::BUILD_SOLUTION_SHAPE_MISMATCH,
42            format!("{what} carries {actual} values; expected {expected}"),
43        ))
44    }
45}
46
47fn duplicate(family: &str, identity: &str) -> Error {
48    Error::new(
49        &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
50        format!("duplicate {family} identity `{identity}`"),
51    )
52}
53
54#[derive(Clone, Debug)]
55struct SolutionIndex {
56    nodes: BTreeMap<(String, String), usize>,
57    line_conductors: BTreeMap<(String, usize), usize>,
58    generators: BTreeMap<String, (usize, usize)>,
59    sources: BTreeMap<String, (usize, usize)>,
60}
61
62impl SolutionIndex {
63    fn new(instance: &LinDist3FlowOpfInstance) -> Result<Self, Error> {
64        let mut nodes = BTreeMap::new();
65        for (position, node) in instance.topology().nodes.iter().enumerate() {
66            let key = (node.bus.to_ascii_lowercase(), node.terminal.clone());
67            if nodes.insert(key, position).is_some() {
68                return Err(duplicate(
69                    "terminal",
70                    &format!("{}/{}", node.bus, node.terminal),
71                ));
72            }
73        }
74
75        let mut line_conductors = BTreeMap::new();
76        for (position, conductor) in instance.topology().conductors.iter().enumerate() {
77            let key = (conductor.line.clone(), conductor.conductor_position);
78            if line_conductors.insert(key, position).is_some() {
79                return Err(duplicate("line conductor", &conductor.line));
80            }
81        }
82
83        let mut generators = BTreeMap::new();
84        let mut offset = 0;
85        for generator in instance.network().generators() {
86            let channels = generator.p_nom.len();
87            if generators
88                .insert(generator.name.clone(), (offset, channels))
89                .is_some()
90            {
91                return Err(duplicate("generator", &generator.name));
92            }
93            offset += channels;
94        }
95
96        let mut sources = BTreeMap::new();
97        let mut offset = 0;
98        for source in instance.network().sources() {
99            let channels = source.terminal_map.len();
100            if sources
101                .insert(source.name.clone(), (offset, channels))
102                .is_some()
103            {
104                return Err(duplicate("voltage source", &source.name));
105            }
106            offset += channels;
107        }
108        Ok(Self {
109            nodes,
110            line_conductors,
111            generators,
112            sources,
113        })
114    }
115}
116
117/// A solution of the fixed-reference LinDist3Flow OPF approximation.
118#[derive(Clone, Debug)]
119pub struct LinDist3FlowOpfSolution {
120    instance: Arc<LinDist3FlowOpfInstance>,
121    termination: Termination,
122    residuals: Residuals,
123    producer: Producer,
124    values: LinDist3FlowOpfValues,
125    objective: f64,
126    index: SolutionIndex,
127}
128
129impl LinDist3FlowOpfSolution {
130    pub const FORMULATION: &'static str = "lindist3flow";
131
132    /// Construct a result in the formulation's documented physical order.
133    ///
134    /// # Errors
135    /// Any primal column disagrees with the instance's node or device axes.
136    pub fn new(
137        instance: Arc<LinDist3FlowOpfInstance>,
138        termination: Termination,
139        values: LinDist3FlowOpfValues,
140        objective: f64,
141    ) -> Result<Self, Error> {
142        let network = instance.network();
143        let nodes = instance.topology().nodes.len();
144        let conductors = instance.topology().conductors.len();
145        let generators = network
146            .generators()
147            .iter()
148            .map(|generator| generator.p_nom.len())
149            .sum();
150        let sources = network
151            .sources()
152            .iter()
153            .map(|source| source.terminal_map.len())
154            .sum();
155        shape(
156            "terminal squared voltage",
157            values.terminal_voltage_magnitude_squared.len(),
158            nodes,
159        )?;
160        shape(
161            "line active power",
162            values.line_active_power.len(),
163            conductors,
164        )?;
165        shape(
166            "line reactive power",
167            values.line_reactive_power.len(),
168            conductors,
169        )?;
170        shape(
171            "generator active power",
172            values.generator_active_power.len(),
173            generators,
174        )?;
175        shape(
176            "generator reactive power",
177            values.generator_reactive_power.len(),
178            generators,
179        )?;
180        shape(
181            "source active power",
182            values.source_active_power.len(),
183            sources,
184        )?;
185        shape(
186            "source reactive power",
187            values.source_reactive_power.len(),
188            sources,
189        )?;
190        let index = SolutionIndex::new(&instance)?;
191        Ok(Self {
192            instance,
193            termination,
194            residuals: Residuals::default(),
195            producer: None,
196            values,
197            objective,
198            index,
199        })
200    }
201
202    #[must_use]
203    pub const fn formulation(&self) -> &'static str {
204        Self::FORMULATION
205    }
206
207    #[must_use]
208    pub fn instance(&self) -> &LinDist3FlowOpfInstance {
209        &self.instance
210    }
211
212    #[must_use]
213    pub fn shared_instance(&self) -> Arc<LinDist3FlowOpfInstance> {
214        Arc::clone(&self.instance)
215    }
216
217    #[must_use]
218    pub fn network(&self) -> &MulticonductorNetwork {
219        self.instance.network()
220    }
221
222    #[must_use]
223    pub const fn termination(&self) -> &Termination {
224        &self.termination
225    }
226
227    #[must_use]
228    pub const fn residuals(&self) -> &Residuals {
229        &self.residuals
230    }
231
232    #[must_use]
233    pub fn producer(&self) -> Option<&str> {
234        self.producer.as_deref()
235    }
236
237    #[must_use]
238    pub const fn values(&self) -> &LinDist3FlowOpfValues {
239        &self.values
240    }
241
242    #[must_use]
243    pub const fn objective(&self) -> f64 {
244        self.objective
245    }
246
247    #[must_use]
248    pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
249        self.producer = Some(producer.into());
250        self
251    }
252
253    #[must_use]
254    pub const fn with_residuals(mut self, residuals: Residuals) -> Self {
255        self.residuals = residuals;
256        self
257    }
258
259    pub fn node_order(&self) -> impl ExactSizeIterator<Item = &LinDist3FlowNode> {
260        self.instance.topology().nodes.iter()
261    }
262
263    #[must_use]
264    pub fn terminal_voltage_magnitude_squared(&self, bus: &str, terminal: &str) -> Option<f64> {
265        let position = self
266            .index
267            .nodes
268            .get(&(bus.to_ascii_lowercase(), terminal.to_owned()))?;
269        Some(self.values.terminal_voltage_magnitude_squared[*position])
270    }
271
272    #[must_use]
273    pub fn terminal_voltage_magnitude(&self, bus: &str, terminal: &str) -> Option<f64> {
274        let squared = self.terminal_voltage_magnitude_squared(bus, terminal)?;
275        (squared >= 0.0).then(|| squared.sqrt())
276    }
277
278    #[must_use]
279    pub fn line_active_power(&self, line: &str, conductor: usize) -> Option<f64> {
280        Some(
281            self.values.line_active_power[*self
282                .index
283                .line_conductors
284                .get(&(line.to_owned(), conductor))?],
285        )
286    }
287
288    #[must_use]
289    pub fn line_reactive_power(&self, line: &str, conductor: usize) -> Option<f64> {
290        Some(
291            self.values.line_reactive_power[*self
292                .index
293                .line_conductors
294                .get(&(line.to_owned(), conductor))?],
295        )
296    }
297
298    fn channel_position(
299        index: &BTreeMap<String, (usize, usize)>,
300        identity: &str,
301        channel: usize,
302    ) -> Option<usize> {
303        let (offset, count) = *index.get(identity)?;
304        (channel < count).then_some(offset + channel)
305    }
306
307    #[must_use]
308    pub fn generator_active_power(&self, generator: &str, channel: usize) -> Option<f64> {
309        Some(
310            self.values.generator_active_power
311                [Self::channel_position(&self.index.generators, generator, channel)?],
312        )
313    }
314
315    #[must_use]
316    pub fn generator_reactive_power(&self, generator: &str, channel: usize) -> Option<f64> {
317        Some(
318            self.values.generator_reactive_power
319                [Self::channel_position(&self.index.generators, generator, channel)?],
320        )
321    }
322
323    #[must_use]
324    pub fn source_active_power(&self, source: &str, channel: usize) -> Option<f64> {
325        Some(
326            self.values.source_active_power
327                [Self::channel_position(&self.index.sources, source, channel)?],
328        )
329    }
330
331    #[must_use]
332    pub fn source_reactive_power(&self, source: &str, channel: usize) -> Option<f64> {
333        Some(
334            self.values.source_reactive_power
335                [Self::channel_position(&self.index.sources, source, channel)?],
336        )
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use crate::{LinDist3FlowBuildOptions, LinDist3FlowOpfInstance};
343    use powerio_dist::{DistBus, DistLine, DistLineCode, MulticonductorNetwork, VoltageSource};
344
345    use super::*;
346
347    fn instance() -> Arc<LinDist3FlowOpfInstance> {
348        let terminal = vec!["1".to_owned()];
349        let mut network = MulticonductorNetwork::named("solution");
350        network
351            .buses_mut()
352            .push(DistBus::new("source", terminal.clone()));
353        network
354            .buses_mut()
355            .push(DistBus::new("load", terminal.clone()));
356        network
357            .line_codes_mut()
358            .push(DistLineCode::new("one", vec![vec![0.1]], vec![vec![0.2]]));
359        network.lines_mut().push(DistLine::new(
360            "line",
361            "source",
362            "load",
363            terminal.clone(),
364            terminal.clone(),
365            "one",
366            1.0,
367        ));
368        network.sources_mut().push(VoltageSource::new(
369            "grid",
370            "source",
371            terminal,
372            vec![230.0],
373            vec![0.0],
374        ));
375        Arc::new(
376            LinDist3FlowOpfInstance::from_network(network, LinDist3FlowBuildOptions::default())
377                .unwrap(),
378        )
379    }
380
381    fn values() -> LinDist3FlowOpfValues {
382        LinDist3FlowOpfValues {
383            terminal_voltage_magnitude_squared: vec![230.0f64.powi(2), 228.0f64.powi(2)],
384            line_active_power: vec![1_000.0],
385            line_reactive_power: vec![200.0],
386            generator_active_power: Vec::new(),
387            generator_reactive_power: Vec::new(),
388            source_active_power: vec![1_000.0],
389            source_reactive_power: vec![200.0],
390        }
391    }
392
393    #[test]
394    fn result_is_keyed_by_formulation_identities() {
395        let instance = instance();
396        let solution = LinDist3FlowOpfSolution::new(
397            Arc::clone(&instance),
398            Termination::Converged,
399            values(),
400            0.3,
401        )
402        .unwrap()
403        .with_producer("test-solver");
404        assert_eq!(solution.formulation(), "lindist3flow");
405        assert_eq!(solution.producer(), Some("test-solver"));
406        assert!((solution.terminal_voltage_magnitude("load", "1").unwrap() - 228.0).abs() < 1e-12);
407        assert!((solution.line_active_power("line", 0).unwrap() - 1_000.0).abs() < 1e-12);
408        assert!((solution.source_reactive_power("grid", 0).unwrap() - 200.0).abs() < 1e-12);
409        assert!(std::ptr::eq(solution.instance(), instance.as_ref()));
410    }
411
412    #[test]
413    fn every_physical_axis_is_shape_checked() {
414        let instance = instance();
415        let mut wrong = values();
416        wrong.line_active_power.push(0.0);
417        assert!(
418            LinDist3FlowOpfSolution::new(instance, Termination::Converged, wrong, 0.0).is_err()
419        );
420    }
421}