Skip to main content

powerio_prob/operating/
multiconductor.rs

1//! Multiconductor operating points over one shared
2//! [`MulticonductorNetwork`] handle, with per terminal addressing.
3
4use std::sync::Arc;
5
6use powerio_core::{Error, TimePoint, TimeSeries};
7use powerio_dist::MulticonductorNetwork;
8
9use super::{
10    OperatingPointColumns, OperatingPointFlags, OperatingPointValues, QuantityLayout,
11    SharedColumns, dense_quantity, sparse_quantity,
12};
13use crate::diagnostics::codes;
14
15/// Multiconductor quantity names. Terminal quantities are keyed
16/// `bus_id/terminal`; per element phase quantities `element_name/terminal`;
17/// whole element quantities by the element's name. Names keep the case the
18/// source supplied.
19const TERMINAL_VOLTAGE_MAGNITUDE: &str = "terminal_voltage_magnitude";
20const TERMINAL_VOLTAGE_ANGLE: &str = "terminal_voltage_angle";
21pub(crate) const LOAD_ACTIVE_POWER: &str = "load_active_power";
22pub(crate) const LOAD_REACTIVE_POWER: &str = "load_reactive_power";
23pub(crate) const GENERATOR_ACTIVE_POWER: &str = "generator_active_power";
24pub(crate) const GENERATOR_REACTIVE_POWER: &str = "generator_reactive_power";
25pub(crate) const SWITCH_CLOSED: &str = "switch_closed";
26const TRANSFORMER_TAP: &str = "transformer_tap";
27const CAPACITOR_STEPS: &str = "capacitor_steps";
28
29/// A numeric multiconductor operating point quantity.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum MulticonductorOperatingPointQuantity {
33    TerminalVoltageMagnitude,
34    TerminalVoltageAngle,
35    LoadActivePower,
36    LoadReactivePower,
37    GeneratorActivePower,
38    GeneratorReactivePower,
39    TransformerTap,
40    CapacitorSteps,
41}
42
43impl MulticonductorOperatingPointQuantity {
44    /// The stable PowerIO IR spelling.
45    #[must_use]
46    pub const fn name(self) -> &'static str {
47        match self {
48            Self::TerminalVoltageMagnitude => TERMINAL_VOLTAGE_MAGNITUDE,
49            Self::TerminalVoltageAngle => TERMINAL_VOLTAGE_ANGLE,
50            Self::LoadActivePower => LOAD_ACTIVE_POWER,
51            Self::LoadReactivePower => LOAD_REACTIVE_POWER,
52            Self::GeneratorActivePower => GENERATOR_ACTIVE_POWER,
53            Self::GeneratorReactivePower => GENERATOR_REACTIVE_POWER,
54            Self::TransformerTap => TRANSFORMER_TAP,
55            Self::CapacitorSteps => CAPACITOR_STEPS,
56        }
57    }
58}
59
60/// A boolean multiconductor operating point quantity.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
62#[non_exhaustive]
63pub enum MulticonductorOperatingPointFlag {
64    SwitchClosed,
65}
66
67impl MulticonductorOperatingPointFlag {
68    /// The stable PowerIO IR spelling.
69    #[must_use]
70    pub const fn name(self) -> &'static str {
71        match self {
72            Self::SwitchClosed => SWITCH_CLOSED,
73        }
74    }
75}
76
77pub use super::OperatingPoint;
78
79impl OperatingPoint<MulticonductorNetwork> {
80    /// Rebind this point to an edited network with the same component and
81    /// terminal identity layouts.
82    pub(crate) fn rebind_network(mut self, network: MulticonductorNetwork) -> Result<Self, Error> {
83        let layout = MulticonductorOperatingPointBuilder::new(network.clone(), Vec::new());
84        for quantity in self.columns.quantities.keys() {
85            let expected: Vec<String> = layout
86                .layout_for(quantity)?
87                .order()
88                .map(str::to_owned)
89                .collect();
90            let actual: Vec<&str> = self
91                .identity_order(quantity)
92                .expect("the quantity came from this point")
93                .collect();
94            if actual.len() != expected.len()
95                || actual
96                    .iter()
97                    .zip(&expected)
98                    .any(|(left, right)| *left != right)
99            {
100                return Err(Error::new(
101                    &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
102                    format!(
103                        "{quantity}: edited network changes the initial point's component identity order"
104                    ),
105                ));
106            }
107        }
108        self.network = network;
109        Ok(self)
110    }
111
112    /// Iterate one numeric quantity in stable component identity order.
113    #[must_use]
114    pub fn values(
115        &self,
116        quantity: MulticonductorOperatingPointQuantity,
117    ) -> Option<OperatingPointValues<'_>> {
118        self.iter_values(quantity.name())
119    }
120
121    /// Iterate one boolean quantity in stable component identity order.
122    #[must_use]
123    pub fn flags(
124        &self,
125        quantity: MulticonductorOperatingPointFlag,
126    ) -> Option<OperatingPointFlags<'_>> {
127        self.iter_flags(quantity.name())
128    }
129
130    /// Voltage magnitude at one bus terminal in volts, keyed
131    /// `bus_id/terminal`.
132    #[must_use]
133    pub fn terminal_voltage_magnitude(&self, bus: &str, terminal: &str) -> Option<f64> {
134        self.value_pair(TERMINAL_VOLTAGE_MAGNITUDE, bus, terminal)
135    }
136
137    /// Voltage angle at one bus terminal in radians.
138    #[must_use]
139    pub fn terminal_voltage_angle(&self, bus: &str, terminal: &str) -> Option<f64> {
140        self.value_pair(TERMINAL_VOLTAGE_ANGLE, bus, terminal)
141    }
142
143    /// Load active power on one conductor in watts, keyed
144    /// `load_name/terminal`.
145    #[must_use]
146    pub fn load_active_power(&self, load: &str, terminal: &str) -> Option<f64> {
147        self.value_pair(LOAD_ACTIVE_POWER, load, terminal)
148    }
149
150    /// Load reactive power on one conductor in vars.
151    #[must_use]
152    pub fn load_reactive_power(&self, load: &str, terminal: &str) -> Option<f64> {
153        self.value_pair(LOAD_REACTIVE_POWER, load, terminal)
154    }
155
156    /// Generator active power on one conductor in watts.
157    #[must_use]
158    pub fn generator_active_power(&self, generator: &str, terminal: &str) -> Option<f64> {
159        self.value_pair(GENERATOR_ACTIVE_POWER, generator, terminal)
160    }
161
162    /// Generator reactive power on one conductor in vars.
163    #[must_use]
164    pub fn generator_reactive_power(&self, generator: &str, terminal: &str) -> Option<f64> {
165        self.value_pair(GENERATOR_REACTIVE_POWER, generator, terminal)
166    }
167
168    /// Whether the named switch is closed at this point.
169    #[must_use]
170    pub fn switch_closed(&self, switch: &str) -> Option<bool> {
171        self.value_single(SWITCH_CLOSED, switch)
172            .map(|value| value != 0.0)
173    }
174
175    /// The named transformer's regulator tap position at this point.
176    #[must_use]
177    pub fn transformer_tap(&self, transformer: &str) -> Option<f64> {
178        self.value_single(TRANSFORMER_TAP, transformer)
179    }
180
181    /// The named capacitor's engaged step count at this point.
182    #[must_use]
183    pub fn capacitor_steps(&self, capacitor: &str) -> Option<f64> {
184        self.value_single(CAPACITOR_STEPS, capacitor)
185    }
186
187    fn value_pair(&self, quantity: &'static str, element: &str, terminal: &str) -> Option<f64> {
188        self.columns
189            .quantities
190            .get(quantity)?
191            .value(self.index, &format!("{element}/{terminal}"))
192    }
193
194    fn value_single(&self, quantity: &'static str, element: &str) -> Option<f64> {
195        self.columns
196            .quantities
197            .get(quantity)?
198            .value(self.index, element)
199    }
200}
201
202/// Bulk constructor for a multiconductor operating point series. Identities
203/// resolve once against the network's stable order: bus terminals in bus
204/// table order with each bus's terminal order, load conductors in load table
205/// order with each terminal map's order, and named elements in their table
206/// order.
207#[derive(Debug)]
208pub struct MulticonductorOperatingPointBuilder {
209    network: MulticonductorNetwork,
210    time_points: Vec<TimePoint>,
211    quantities: Vec<(&'static str, ColumnsInput)>,
212}
213
214#[derive(Debug)]
215enum ColumnsInput {
216    Dense(Vec<f64>),
217    Sparse {
218        base: Vec<f64>,
219        changes: Vec<Vec<(String, f64)>>,
220    },
221}
222
223impl MulticonductorOperatingPointBuilder {
224    #[must_use]
225    pub fn new(network: MulticonductorNetwork, time_points: Vec<TimePoint>) -> Self {
226        Self {
227            network,
228            time_points,
229            quantities: Vec::new(),
230        }
231    }
232
233    /// Start a builder for one operating point.
234    #[must_use]
235    pub fn for_point(network: MulticonductorNetwork) -> Self {
236        Self::new(network, Vec::new())
237    }
238
239    fn dense(mut self, quantity: &'static str, values: Vec<f64>) -> Self {
240        self.quantities
241            .push((quantity, ColumnsInput::Dense(values)));
242        self
243    }
244
245    fn sparse(
246        mut self,
247        quantity: &'static str,
248        base: Vec<f64>,
249        changes: Vec<Vec<(String, f64)>>,
250    ) -> Self {
251        self.quantities
252            .push((quantity, ColumnsInput::Sparse { base, changes }));
253        self
254    }
255
256    fn sparse_flags(
257        self,
258        quantity: &'static str,
259        base: Vec<bool>,
260        changes: Vec<Vec<(String, bool)>>,
261    ) -> Self {
262        self.sparse(
263            quantity,
264            encode_flags(base),
265            changes
266                .into_iter()
267                .map(|point| {
268                    point
269                        .into_iter()
270                        .map(|(identity, value)| (identity, encode_flag(value)))
271                        .collect()
272                })
273                .collect(),
274        )
275    }
276
277    /// Dense terminal voltage magnitudes: one value per bus terminal in bus
278    /// table order (each bus's terminals in its stated order), point major.
279    #[must_use]
280    pub fn terminal_voltage_magnitudes(self, values: Vec<f64>) -> Self {
281        self.dense(TERMINAL_VOLTAGE_MAGNITUDE, values)
282    }
283
284    #[must_use]
285    pub fn terminal_voltage_angles(self, values: Vec<f64>) -> Self {
286        self.dense(TERMINAL_VOLTAGE_ANGLE, values)
287    }
288
289    /// Dense per conductor load active powers: one value per load terminal in
290    /// load table order (each load's terminal map order), point major.
291    #[must_use]
292    pub fn load_active_powers(self, values: Vec<f64>) -> Self {
293        self.dense(LOAD_ACTIVE_POWER, values)
294    }
295
296    #[must_use]
297    pub fn load_reactive_powers(self, values: Vec<f64>) -> Self {
298        self.dense(LOAD_REACTIVE_POWER, values)
299    }
300
301    #[must_use]
302    pub fn generator_active_powers(self, values: Vec<f64>) -> Self {
303        self.dense(GENERATOR_ACTIVE_POWER, values)
304    }
305
306    #[must_use]
307    pub fn generator_reactive_powers(self, values: Vec<f64>) -> Self {
308        self.dense(GENERATOR_REACTIVE_POWER, values)
309    }
310
311    #[must_use]
312    pub fn switch_closed(self, values: Vec<bool>) -> Self {
313        self.dense(SWITCH_CLOSED, encode_flags(values))
314    }
315
316    #[must_use]
317    pub fn transformer_taps(self, values: Vec<f64>) -> Self {
318        self.dense(TRANSFORMER_TAP, values)
319    }
320
321    #[must_use]
322    pub fn capacitor_steps(self, values: Vec<f64>) -> Self {
323        self.dense(CAPACITOR_STEPS, values)
324    }
325
326    /// Sparse terminal voltage magnitudes: one base row plus per point
327    /// overrides keyed `bus_id/terminal`.
328    #[must_use]
329    pub fn sparse_terminal_voltage_magnitudes(
330        self,
331        base: Vec<f64>,
332        changes: Vec<Vec<(String, f64)>>,
333    ) -> Self {
334        self.sparse(TERMINAL_VOLTAGE_MAGNITUDE, base, changes)
335    }
336
337    #[must_use]
338    pub fn sparse_terminal_voltage_angles(
339        self,
340        base: Vec<f64>,
341        changes: Vec<Vec<(String, f64)>>,
342    ) -> Self {
343        self.sparse(TERMINAL_VOLTAGE_ANGLE, base, changes)
344    }
345
346    /// Sparse per conductor load active powers: one base row plus per point
347    /// overrides keyed `load_name/terminal`.
348    #[must_use]
349    pub fn sparse_load_active_powers(
350        self,
351        base: Vec<f64>,
352        changes: Vec<Vec<(String, f64)>>,
353    ) -> Self {
354        self.sparse(LOAD_ACTIVE_POWER, base, changes)
355    }
356
357    #[must_use]
358    pub fn sparse_load_reactive_powers(
359        self,
360        base: Vec<f64>,
361        changes: Vec<Vec<(String, f64)>>,
362    ) -> Self {
363        self.sparse(LOAD_REACTIVE_POWER, base, changes)
364    }
365
366    #[must_use]
367    pub fn sparse_generator_active_powers(
368        self,
369        base: Vec<f64>,
370        changes: Vec<Vec<(String, f64)>>,
371    ) -> Self {
372        self.sparse(GENERATOR_ACTIVE_POWER, base, changes)
373    }
374
375    #[must_use]
376    pub fn sparse_generator_reactive_powers(
377        self,
378        base: Vec<f64>,
379        changes: Vec<Vec<(String, f64)>>,
380    ) -> Self {
381        self.sparse(GENERATOR_REACTIVE_POWER, base, changes)
382    }
383
384    #[must_use]
385    pub fn sparse_switch_closed(self, base: Vec<bool>, changes: Vec<Vec<(String, bool)>>) -> Self {
386        self.sparse_flags(SWITCH_CLOSED, base, changes)
387    }
388
389    #[must_use]
390    pub fn sparse_transformer_taps(self, base: Vec<f64>, changes: Vec<Vec<(String, f64)>>) -> Self {
391        self.sparse(TRANSFORMER_TAP, base, changes)
392    }
393
394    #[must_use]
395    pub fn sparse_capacitor_steps(self, base: Vec<f64>, changes: Vec<Vec<(String, f64)>>) -> Self {
396        self.sparse(CAPACITOR_STEPS, base, changes)
397    }
398
399    fn layout_for(&self, quantity: &'static str) -> Result<QuantityLayout, Error> {
400        let network = &self.network;
401        match quantity {
402            TERMINAL_VOLTAGE_MAGNITUDE | TERMINAL_VOLTAGE_ANGLE => QuantityLayout::from_order(
403                quantity,
404                network.buses().iter().flat_map(|bus| {
405                    bus.terminals
406                        .iter()
407                        .map(move |terminal| format!("{}/{terminal}", bus.id))
408                }),
409            ),
410            LOAD_ACTIVE_POWER | LOAD_REACTIVE_POWER => QuantityLayout::from_order(
411                quantity,
412                network.loads().iter().flat_map(|load| {
413                    load.terminal_map
414                        .iter()
415                        .map(move |terminal| format!("{}/{terminal}", load.name))
416                }),
417            ),
418            GENERATOR_ACTIVE_POWER | GENERATOR_REACTIVE_POWER => QuantityLayout::from_order(
419                quantity,
420                network.generators().iter().flat_map(|generator| {
421                    generator
422                        .terminal_map
423                        .iter()
424                        .map(move |terminal| format!("{}/{terminal}", generator.name))
425                }),
426            ),
427            SWITCH_CLOSED => QuantityLayout::from_order(
428                quantity,
429                network.switches().iter().map(|s| s.name.clone()),
430            ),
431            TRANSFORMER_TAP => QuantityLayout::from_order(
432                quantity,
433                network.transformers().iter().map(|t| t.name.clone()),
434            ),
435            CAPACITOR_STEPS => QuantityLayout::from_order(
436                quantity,
437                network.capacitors().iter().map(|c| c.name.clone()),
438            ),
439            _ => unreachable!("builder methods name registered quantities"),
440        }
441    }
442
443    fn build_points(
444        &self,
445        point_count: usize,
446    ) -> Result<Vec<OperatingPoint<MulticonductorNetwork>>, Error> {
447        let mut quantities = std::collections::HashMap::new();
448        for (quantity, input) in &self.quantities {
449            let layout = self.layout_for(quantity)?;
450            let built = match input {
451                ColumnsInput::Dense(values) => {
452                    dense_quantity(quantity, layout, point_count, values.clone())?
453                }
454                ColumnsInput::Sparse { base, changes } => {
455                    sparse_quantity(quantity, layout, point_count, base.clone(), changes.clone())?
456                }
457            };
458            if quantities.insert(*quantity, built).is_some() {
459                return Err(Error::new(
460                    &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
461                    format!("{quantity} was supplied twice"),
462                ));
463            }
464        }
465        let columns: SharedColumns = Arc::new(OperatingPointColumns {
466            point_count,
467            quantities,
468        });
469        Ok((0..point_count)
470            .map(|index| OperatingPoint {
471                network: self.network.clone(),
472                columns: Arc::clone(&columns),
473                index,
474            })
475            .collect())
476    }
477
478    /// Resolve every identity once, validate every column, and build the
479    /// series. The points share the network handle and the columns; QSTS
480    /// result import goes through here without cloning the network per
481    /// sample.
482    ///
483    /// # Errors
484    /// A shape mismatch, a duplicate or unknown identity, or an empty time
485    /// axis.
486    pub fn build(self) -> Result<TimeSeries<OperatingPoint<MulticonductorNetwork>>, Error> {
487        let point_count = self.time_points.len();
488        if point_count == 0 {
489            return Err(Error::new(
490                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
491                "an operating point series needs at least one time point",
492            ));
493        }
494        let points = self.build_points(point_count)?;
495        TimeSeries::new(self.time_points, points)
496    }
497
498    /// Build one operating point.
499    ///
500    /// # Errors
501    /// The builder does not contain exactly one point, or a quantity is invalid.
502    pub fn build_point(self) -> Result<OperatingPoint<MulticonductorNetwork>, Error> {
503        if self.time_points.len() > 1 {
504            return Err(Error::new(
505                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
506                "a scalar operating point builder cannot contain several time points",
507            ));
508        }
509        self.build_points(1)?.pop().ok_or_else(|| {
510            Error::new(
511                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
512                "the scalar operating point builder produced no point",
513            )
514        })
515    }
516}
517
518fn encode_flags(values: Vec<bool>) -> Vec<f64> {
519    values.into_iter().map(encode_flag).collect()
520}
521
522fn encode_flag(value: bool) -> f64 {
523    if value { 1.0 } else { 0.0 }
524}