Skip to main content

powerio_prob/operating/
balanced.rs

1//! Balanced operating points over one shared
2//! [`BalancedNetwork`] handle.
3
4use std::sync::Arc;
5
6use powerio_core::{Error, TimePoint, TimeSeries};
7use powerio_tx::{BalancedNetwork, BusId};
8
9use super::{
10    OperatingPoint, OperatingPointColumns, OperatingPointFlags, OperatingPointValues,
11    QuantityLayout, SharedColumns, dense_quantity, row_identity, sparse_quantity,
12};
13use crate::diagnostics::codes;
14
15/// The balanced instantaneous quantity names. Bus quantities are keyed by the
16/// bus ID's decimal spelling; element quantities by the element's payload
17/// persistent identity. The row based fallback exists only for released
18/// stored documents that predate parser assigned component identities; new
19/// parsers assign and retain `uid` values before building an operating point.
20const BUS_VOLTAGE_MAGNITUDE: &str = "bus_voltage_magnitude";
21const BUS_VOLTAGE_ANGLE: &str = "bus_voltage_angle";
22const BUS_ACTIVE_INJECTION: &str = "bus_active_injection";
23const BUS_REACTIVE_INJECTION: &str = "bus_reactive_injection";
24pub(crate) const GENERATOR_ACTIVE_POWER: &str = "generator_active_power";
25pub(crate) const GENERATOR_REACTIVE_POWER: &str = "generator_reactive_power";
26pub(crate) const GENERATOR_VOLTAGE_SETPOINT: &str = "generator_voltage_setpoint";
27pub(crate) const GENERATOR_IN_SERVICE: &str = "generator_in_service";
28pub(crate) const LOAD_ACTIVE_POWER: &str = "load_active_power";
29pub(crate) const LOAD_REACTIVE_POWER: &str = "load_reactive_power";
30pub(crate) const BRANCH_IN_SERVICE: &str = "branch_in_service";
31pub(crate) const BRANCH_TAP_RATIO: &str = "branch_tap_ratio";
32pub(crate) const BRANCH_PHASE_SHIFT: &str = "branch_phase_shift";
33pub(crate) const SWITCH_CLOSED: &str = "switch_closed";
34
35/// A numeric balanced operating point quantity.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub enum BalancedOperatingPointQuantity {
39    BusVoltageMagnitude,
40    BusVoltageAngle,
41    BusActiveInjection,
42    BusReactiveInjection,
43    GeneratorActivePower,
44    GeneratorReactivePower,
45    GeneratorVoltageSetpoint,
46    LoadActivePower,
47    LoadReactivePower,
48    BranchTapRatio,
49    BranchPhaseShift,
50}
51
52impl BalancedOperatingPointQuantity {
53    /// The stable PowerIO IR spelling.
54    #[must_use]
55    pub const fn name(self) -> &'static str {
56        match self {
57            Self::BusVoltageMagnitude => BUS_VOLTAGE_MAGNITUDE,
58            Self::BusVoltageAngle => BUS_VOLTAGE_ANGLE,
59            Self::BusActiveInjection => BUS_ACTIVE_INJECTION,
60            Self::BusReactiveInjection => BUS_REACTIVE_INJECTION,
61            Self::GeneratorActivePower => GENERATOR_ACTIVE_POWER,
62            Self::GeneratorReactivePower => GENERATOR_REACTIVE_POWER,
63            Self::GeneratorVoltageSetpoint => GENERATOR_VOLTAGE_SETPOINT,
64            Self::LoadActivePower => LOAD_ACTIVE_POWER,
65            Self::LoadReactivePower => LOAD_REACTIVE_POWER,
66            Self::BranchTapRatio => BRANCH_TAP_RATIO,
67            Self::BranchPhaseShift => BRANCH_PHASE_SHIFT,
68        }
69    }
70}
71
72/// A boolean balanced operating point quantity.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum BalancedOperatingPointFlag {
76    GeneratorInService,
77    BranchInService,
78    SwitchClosed,
79}
80
81impl BalancedOperatingPointFlag {
82    /// The stable PowerIO IR spelling.
83    #[must_use]
84    pub const fn name(self) -> &'static str {
85        match self {
86            Self::GeneratorInService => GENERATOR_IN_SERVICE,
87            Self::BranchInService => BRANCH_IN_SERVICE,
88            Self::SwitchClosed => SWITCH_CLOSED,
89        }
90    }
91}
92
93impl OperatingPoint<BalancedNetwork> {
94    /// Rebind this point to an edited network with the same element identity
95    /// layout. Used when an instance changes parameters such as branch ratings
96    /// without changing which columns an initial point addresses.
97    pub(crate) fn rebind_network(mut self, network: BalancedNetwork) -> Result<Self, Error> {
98        let layout = BalancedOperatingPointBuilder::new(network.clone(), Vec::new());
99        for quantity in self.columns.quantities.keys() {
100            let expected = layout.identity_order(quantity)?;
101            let actual: Vec<&str> = self
102                .identity_order(quantity)
103                .expect("the quantity came from this point")
104                .collect();
105            if actual.len() != expected.len() || actual.iter().zip(&expected).any(|(a, b)| *a != b)
106            {
107                return Err(Error::new(
108                    &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
109                    format!(
110                        "{quantity}: edited network changes the initial point's element identity order"
111                    ),
112                ));
113            }
114        }
115        self.network = network;
116        Ok(self)
117    }
118
119    /// Iterate one numeric quantity in stable component identity order.
120    #[must_use]
121    pub fn values(
122        &self,
123        quantity: BalancedOperatingPointQuantity,
124    ) -> Option<OperatingPointValues<'_>> {
125        self.iter_values(quantity.name())
126    }
127
128    /// Iterate one boolean quantity in stable component identity order.
129    #[must_use]
130    pub fn flags(&self, quantity: BalancedOperatingPointFlag) -> Option<OperatingPointFlags<'_>> {
131        self.iter_flags(quantity.name())
132    }
133
134    /// Bus voltage magnitude in per unit, `None` when the point contains no
135    /// voltages or the bus is unknown.
136    #[must_use]
137    pub fn bus_voltage_magnitude(&self, bus: BusId) -> Option<f64> {
138        self.value(BUS_VOLTAGE_MAGNITUDE, &bus.0.to_string())
139    }
140
141    /// Bus voltage angle in radians.
142    #[must_use]
143    pub fn bus_voltage_angle(&self, bus: BusId) -> Option<f64> {
144        self.value(BUS_VOLTAGE_ANGLE, &bus.0.to_string())
145    }
146
147    /// Net active injection at the bus in MW.
148    #[must_use]
149    pub fn bus_active_injection(&self, bus: BusId) -> Option<f64> {
150        self.value(BUS_ACTIVE_INJECTION, &bus.0.to_string())
151    }
152
153    /// Net reactive injection at the bus in MVAr.
154    #[must_use]
155    pub fn bus_reactive_injection(&self, bus: BusId) -> Option<f64> {
156        self.value(BUS_REACTIVE_INJECTION, &bus.0.to_string())
157    }
158
159    /// Generator active power output in MW, by payload identity.
160    #[must_use]
161    pub fn generator_active_power(&self, identity: &str) -> Option<f64> {
162        self.value(GENERATOR_ACTIVE_POWER, identity)
163    }
164
165    /// Generator reactive power output in MVAr, by payload identity.
166    #[must_use]
167    pub fn generator_reactive_power(&self, identity: &str) -> Option<f64> {
168        self.value(GENERATOR_REACTIVE_POWER, identity)
169    }
170
171    /// Generator voltage setpoint in per unit, by payload identity.
172    #[must_use]
173    pub fn generator_voltage_setpoint(&self, identity: &str) -> Option<f64> {
174        self.value(GENERATOR_VOLTAGE_SETPOINT, identity)
175    }
176
177    /// Whether the generator is in service at this point.
178    #[must_use]
179    pub fn generator_in_service(&self, identity: &str) -> Option<bool> {
180        self.value(GENERATOR_IN_SERVICE, identity)
181            .map(|value| value != 0.0)
182    }
183
184    /// Load active power in MW, by payload identity.
185    #[must_use]
186    pub fn load_active_power(&self, identity: &str) -> Option<f64> {
187        self.value(LOAD_ACTIVE_POWER, identity)
188    }
189
190    /// Load reactive power in MVAr, by payload identity.
191    #[must_use]
192    pub fn load_reactive_power(&self, identity: &str) -> Option<f64> {
193        self.value(LOAD_REACTIVE_POWER, identity)
194    }
195
196    /// Whether the branch is in service at this point.
197    #[must_use]
198    pub fn branch_in_service(&self, identity: &str) -> Option<bool> {
199        self.value(BRANCH_IN_SERVICE, identity)
200            .map(|value| value != 0.0)
201    }
202
203    /// Branch off-nominal tap ratio at this point.
204    #[must_use]
205    pub fn branch_tap_ratio(&self, identity: &str) -> Option<f64> {
206        self.value(BRANCH_TAP_RATIO, identity)
207    }
208
209    /// Branch phase shift in degrees at this point.
210    #[must_use]
211    pub fn branch_phase_shift(&self, identity: &str) -> Option<f64> {
212        self.value(BRANCH_PHASE_SHIFT, identity)
213    }
214
215    /// Whether the switch is closed at this point.
216    #[must_use]
217    pub fn switch_closed(&self, identity: &str) -> Option<bool> {
218        self.value(SWITCH_CLOSED, identity)
219            .map(|value| value != 0.0)
220    }
221}
222
223/// Bulk constructor for a balanced operating point series. Identities resolve
224/// once against the network's stable order — buses in bus table order by ID,
225/// elements in table order by payload identity — and every column is
226/// validated against that order and the point count. Dense columns are point
227/// major; sparse columns override one base row per point by identity.
228#[derive(Debug)]
229pub struct BalancedOperatingPointBuilder {
230    network: BalancedNetwork,
231    time_points: Vec<TimePoint>,
232    quantities: Vec<(&'static str, ColumnsInput)>,
233}
234
235#[derive(Debug)]
236enum ColumnsInput {
237    Dense(Vec<f64>),
238    Sparse {
239        base: Vec<f64>,
240        changes: Vec<Vec<(String, f64)>>,
241    },
242}
243
244#[derive(Clone, Copy, Debug, PartialEq, Eq)]
245enum KeyFamily {
246    Bus,
247    Generator,
248    Load,
249    Branch,
250    Switch,
251}
252
253fn family_of(quantity: &'static str) -> KeyFamily {
254    match quantity {
255        BUS_VOLTAGE_MAGNITUDE
256        | BUS_VOLTAGE_ANGLE
257        | BUS_ACTIVE_INJECTION
258        | BUS_REACTIVE_INJECTION => KeyFamily::Bus,
259        GENERATOR_ACTIVE_POWER
260        | GENERATOR_REACTIVE_POWER
261        | GENERATOR_VOLTAGE_SETPOINT
262        | GENERATOR_IN_SERVICE => KeyFamily::Generator,
263        LOAD_ACTIVE_POWER | LOAD_REACTIVE_POWER => KeyFamily::Load,
264        BRANCH_IN_SERVICE | BRANCH_TAP_RATIO | BRANCH_PHASE_SHIFT => KeyFamily::Branch,
265        SWITCH_CLOSED => KeyFamily::Switch,
266        _ => unreachable!("builder methods name registered quantities"),
267    }
268}
269
270impl BalancedOperatingPointBuilder {
271    #[must_use]
272    pub fn new(mut network: BalancedNetwork, time_points: Vec<TimePoint>) -> Self {
273        network.assign_missing_component_ids();
274        Self {
275            network,
276            time_points,
277            quantities: Vec::new(),
278        }
279    }
280
281    /// Start a builder for one operating point.
282    #[must_use]
283    pub fn for_point(network: BalancedNetwork) -> Self {
284        Self::new(network, Vec::new())
285    }
286
287    fn dense(mut self, quantity: &'static str, values: Vec<f64>) -> Self {
288        self.quantities
289            .push((quantity, ColumnsInput::Dense(values)));
290        self
291    }
292
293    fn sparse(
294        mut self,
295        quantity: &'static str,
296        base: Vec<f64>,
297        changes: Vec<Vec<(String, f64)>>,
298    ) -> Self {
299        self.quantities
300            .push((quantity, ColumnsInput::Sparse { base, changes }));
301        self
302    }
303
304    fn sparse_flags(
305        self,
306        quantity: &'static str,
307        base: Vec<bool>,
308        changes: Vec<Vec<(String, bool)>>,
309    ) -> Self {
310        self.sparse(
311            quantity,
312            encode_flags(base),
313            changes
314                .into_iter()
315                .map(|point| {
316                    point
317                        .into_iter()
318                        .map(|(identity, value)| (identity, encode_flag(value)))
319                        .collect()
320                })
321                .collect(),
322        )
323    }
324
325    /// Dense bus voltage magnitudes: `point_count * n_buses` values in bus
326    /// table order, point major.
327    #[must_use]
328    pub fn bus_voltage_magnitudes(self, values: Vec<f64>) -> Self {
329        self.dense(BUS_VOLTAGE_MAGNITUDE, values)
330    }
331
332    #[must_use]
333    pub fn bus_voltage_angles(self, values: Vec<f64>) -> Self {
334        self.dense(BUS_VOLTAGE_ANGLE, values)
335    }
336
337    #[must_use]
338    pub fn bus_active_injections(self, values: Vec<f64>) -> Self {
339        self.dense(BUS_ACTIVE_INJECTION, values)
340    }
341
342    #[must_use]
343    pub fn bus_reactive_injections(self, values: Vec<f64>) -> Self {
344        self.dense(BUS_REACTIVE_INJECTION, values)
345    }
346
347    #[must_use]
348    pub fn generator_active_powers(self, values: Vec<f64>) -> Self {
349        self.dense(GENERATOR_ACTIVE_POWER, values)
350    }
351
352    #[must_use]
353    pub fn generator_reactive_powers(self, values: Vec<f64>) -> Self {
354        self.dense(GENERATOR_REACTIVE_POWER, values)
355    }
356
357    #[must_use]
358    pub fn generator_voltage_setpoints(self, values: Vec<f64>) -> Self {
359        self.dense(GENERATOR_VOLTAGE_SETPOINT, values)
360    }
361
362    #[must_use]
363    pub fn generator_in_service(self, values: Vec<bool>) -> Self {
364        self.dense(GENERATOR_IN_SERVICE, encode_flags(values))
365    }
366
367    #[must_use]
368    pub fn load_active_powers(self, values: Vec<f64>) -> Self {
369        self.dense(LOAD_ACTIVE_POWER, values)
370    }
371
372    #[must_use]
373    pub fn load_reactive_powers(self, values: Vec<f64>) -> Self {
374        self.dense(LOAD_REACTIVE_POWER, values)
375    }
376
377    #[must_use]
378    pub fn branch_in_service(self, values: Vec<bool>) -> Self {
379        self.dense(BRANCH_IN_SERVICE, encode_flags(values))
380    }
381
382    #[must_use]
383    pub fn branch_tap_ratios(self, values: Vec<f64>) -> Self {
384        self.dense(BRANCH_TAP_RATIO, values)
385    }
386
387    #[must_use]
388    pub fn branch_phase_shifts(self, values: Vec<f64>) -> Self {
389        self.dense(BRANCH_PHASE_SHIFT, values)
390    }
391
392    #[must_use]
393    pub fn switch_closed(self, values: Vec<bool>) -> Self {
394        self.dense(SWITCH_CLOSED, encode_flags(values))
395    }
396
397    /// Sparse bus voltage magnitudes: one base row plus per point overrides
398    /// keyed by bus identity.
399    #[must_use]
400    pub fn sparse_bus_voltage_magnitudes(
401        self,
402        base: Vec<f64>,
403        changes: Vec<Vec<(String, f64)>>,
404    ) -> Self {
405        self.sparse(BUS_VOLTAGE_MAGNITUDE, base, changes)
406    }
407
408    #[must_use]
409    pub fn sparse_bus_voltage_angles(
410        self,
411        base: Vec<f64>,
412        changes: Vec<Vec<(String, f64)>>,
413    ) -> Self {
414        self.sparse(BUS_VOLTAGE_ANGLE, base, changes)
415    }
416
417    #[must_use]
418    pub fn sparse_bus_active_injections(
419        self,
420        base: Vec<f64>,
421        changes: Vec<Vec<(String, f64)>>,
422    ) -> Self {
423        self.sparse(BUS_ACTIVE_INJECTION, base, changes)
424    }
425
426    #[must_use]
427    pub fn sparse_bus_reactive_injections(
428        self,
429        base: Vec<f64>,
430        changes: Vec<Vec<(String, f64)>>,
431    ) -> Self {
432        self.sparse(BUS_REACTIVE_INJECTION, base, changes)
433    }
434
435    #[must_use]
436    pub fn sparse_generator_active_powers(
437        self,
438        base: Vec<f64>,
439        changes: Vec<Vec<(String, f64)>>,
440    ) -> Self {
441        self.sparse(GENERATOR_ACTIVE_POWER, base, changes)
442    }
443
444    #[must_use]
445    pub fn sparse_generator_reactive_powers(
446        self,
447        base: Vec<f64>,
448        changes: Vec<Vec<(String, f64)>>,
449    ) -> Self {
450        self.sparse(GENERATOR_REACTIVE_POWER, base, changes)
451    }
452
453    #[must_use]
454    pub fn sparse_generator_voltage_setpoints(
455        self,
456        base: Vec<f64>,
457        changes: Vec<Vec<(String, f64)>>,
458    ) -> Self {
459        self.sparse(GENERATOR_VOLTAGE_SETPOINT, base, changes)
460    }
461
462    #[must_use]
463    pub fn sparse_generator_in_service(
464        self,
465        base: Vec<bool>,
466        changes: Vec<Vec<(String, bool)>>,
467    ) -> Self {
468        self.sparse_flags(GENERATOR_IN_SERVICE, base, changes)
469    }
470
471    /// Sparse load active powers: one base row in load table order plus per
472    /// point overrides keyed by payload identity.
473    #[must_use]
474    pub fn sparse_load_active_powers(
475        self,
476        base: Vec<f64>,
477        changes: Vec<Vec<(String, f64)>>,
478    ) -> Self {
479        self.sparse(LOAD_ACTIVE_POWER, base, changes)
480    }
481
482    #[must_use]
483    pub fn sparse_load_reactive_powers(
484        self,
485        base: Vec<f64>,
486        changes: Vec<Vec<(String, f64)>>,
487    ) -> Self {
488        self.sparse(LOAD_REACTIVE_POWER, base, changes)
489    }
490
491    #[must_use]
492    pub fn sparse_branch_in_service(
493        self,
494        base: Vec<bool>,
495        changes: Vec<Vec<(String, bool)>>,
496    ) -> Self {
497        self.sparse_flags(BRANCH_IN_SERVICE, base, changes)
498    }
499
500    #[must_use]
501    pub fn sparse_branch_tap_ratios(
502        self,
503        base: Vec<f64>,
504        changes: Vec<Vec<(String, f64)>>,
505    ) -> Self {
506        self.sparse(BRANCH_TAP_RATIO, base, changes)
507    }
508
509    #[must_use]
510    pub fn sparse_branch_phase_shifts(
511        self,
512        base: Vec<f64>,
513        changes: Vec<Vec<(String, f64)>>,
514    ) -> Self {
515        self.sparse(BRANCH_PHASE_SHIFT, base, changes)
516    }
517
518    #[must_use]
519    pub fn sparse_switch_closed(self, base: Vec<bool>, changes: Vec<Vec<(String, bool)>>) -> Self {
520        self.sparse_flags(SWITCH_CLOSED, base, changes)
521    }
522
523    /// The identity order the network resolves for a quantity: the exact
524    /// sequence a dense column binds to.
525    fn identity_order(&self, quantity: &'static str) -> Result<Vec<String>, Error> {
526        Ok(self
527            .layout_for(quantity)?
528            .order()
529            .map(str::to_string)
530            .collect())
531    }
532
533    fn layout_for(&self, quantity: &'static str) -> Result<QuantityLayout, Error> {
534        let network = &self.network;
535        match family_of(quantity) {
536            KeyFamily::Bus => QuantityLayout::from_order(
537                quantity,
538                network.buses().iter().map(|bus| bus.id.0.to_string()),
539            ),
540            KeyFamily::Generator => QuantityLayout::from_order(
541                quantity,
542                network
543                    .generators()
544                    .iter()
545                    .enumerate()
546                    .map(|(row, g)| row_identity(g.uid.as_deref(), "generators", row)),
547            ),
548            KeyFamily::Load => QuantityLayout::from_order(
549                quantity,
550                network
551                    .loads()
552                    .iter()
553                    .enumerate()
554                    .map(|(row, l)| row_identity(l.uid.as_deref(), "loads", row)),
555            ),
556            KeyFamily::Branch => QuantityLayout::from_order(
557                quantity,
558                network
559                    .branches()
560                    .iter()
561                    .enumerate()
562                    .map(|(row, b)| row_identity(b.uid.as_deref(), "branches", row)),
563            ),
564            KeyFamily::Switch => QuantityLayout::from_order(
565                quantity,
566                network
567                    .switches()
568                    .iter()
569                    .enumerate()
570                    .map(|(row, s)| row_identity(s.uid.as_deref(), "switches", row)),
571            ),
572        }
573    }
574
575    fn build_points(
576        &self,
577        point_count: usize,
578    ) -> Result<Vec<OperatingPoint<BalancedNetwork>>, Error> {
579        let mut quantities = std::collections::HashMap::new();
580        for (quantity, input) in &self.quantities {
581            let layout = self.layout_for(quantity)?;
582            let built = match input {
583                ColumnsInput::Dense(values) => {
584                    dense_quantity(quantity, layout, point_count, values.clone())?
585                }
586                ColumnsInput::Sparse { base, changes } => {
587                    sparse_quantity(quantity, layout, point_count, base.clone(), changes.clone())?
588                }
589            };
590            if quantities.insert(*quantity, built).is_some() {
591                return Err(Error::new(
592                    &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
593                    format!("{quantity} was supplied twice"),
594                ));
595            }
596        }
597        let columns: SharedColumns = Arc::new(OperatingPointColumns {
598            point_count,
599            quantities,
600        });
601        Ok((0..point_count)
602            .map(|index| OperatingPoint {
603                network: self.network.clone(),
604                columns: Arc::clone(&columns),
605                index,
606            })
607            .collect())
608    }
609
610    /// Resolve every identity once, validate every column, and build the
611    /// series. The points share the network handle and the columns.
612    ///
613    /// # Errors
614    /// A shape mismatch, a duplicate or unknown identity, or a time axis
615    /// whose length disagrees with the columns.
616    pub fn build(self) -> Result<TimeSeries<OperatingPoint<BalancedNetwork>>, Error> {
617        let point_count = self.time_points.len();
618        if point_count == 0 {
619            return Err(Error::new(
620                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
621                "an operating point series needs at least one time point",
622            ));
623        }
624        let points = self.build_points(point_count)?;
625        TimeSeries::new(self.time_points, points)
626    }
627
628    /// Build one operating point.
629    ///
630    /// # Errors
631    /// The builder does not contain exactly one point, or a quantity is invalid.
632    pub fn build_point(self) -> Result<OperatingPoint<BalancedNetwork>, Error> {
633        if self.time_points.len() > 1 {
634            return Err(Error::new(
635                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
636                "a scalar operating point builder cannot contain several time points",
637            ));
638        }
639        self.build_points(1)?.pop().ok_or_else(|| {
640            Error::new(
641                &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
642                "the scalar operating point builder produced no point",
643            )
644        })
645    }
646}
647
648fn encode_flags(values: Vec<bool>) -> Vec<f64> {
649    values.into_iter().map(encode_flag).collect()
650}
651
652fn encode_flag(value: bool) -> f64 {
653    if value { 1.0 } else { 0.0 }
654}