Skip to main content

powerio/
value.rs

1//! The dynamic Rust boundary for values produced by universal parsing.
2
3use powerio_core::{Scenario, ScenarioSet, TimePoint, TimeSeries};
4use powerio_dist::MulticonductorNetwork;
5use powerio_prob::OperatingPoint;
6use powerio_tx::{BalancedNetwork, ContingencySet, GeoLayer, MonitoredSet, SubsystemSet};
7
8/// A dynamically typed time series at the universal parser boundary.
9///
10/// Typed Rust code uses `TimeSeries<T>` directly. This wrapper preserves the
11/// element type for empty collections and lets C and PowerIO IR report the
12/// same structural type name without a flattened collection registry.
13#[derive(Clone, Debug)]
14pub struct PioTimeSeries {
15    element_type: Box<str>,
16    type_name: Box<str>,
17    values: TimeSeries<PioValue>,
18}
19
20impl PioTimeSeries {
21    fn from_typed<T>(element_type: &'static str, values: TimeSeries<T>) -> Self
22    where
23        T: Clone + Into<PioValue>,
24    {
25        let values = values.map_values(Into::into);
26        debug_assert!(
27            values
28                .values()
29                .iter()
30                .all(|value| value.type_name() == element_type)
31        );
32        Self {
33            element_type: element_type.into(),
34            type_name: format!("powerio.TimeSeries<{element_type}>").into_boxed_str(),
35            values,
36        }
37    }
38
39    /// Construct the dynamic form from values that already crossed the
40    /// universal parser boundary.
41    ///
42    /// Typed Rust code should use [`TimeSeries<T>`] and its ordinary
43    /// [`From`] conversion. This constructor exists for dynamic language
44    /// bindings. An empty input has no value from which to infer `T` and is
45    /// rejected.
46    pub fn from_values(
47        time_points: Vec<TimePoint>,
48        values: Vec<PioValue>,
49    ) -> Result<Self, powerio_core::Error> {
50        let Some(first) = values.first() else {
51            return Err(powerio_core::Error::new(
52                &crate::codes::VALIDATE_COLLECTION_EMPTY,
53                "a time series needs at least one value to infer its element type",
54            ));
55        };
56        let element_type = first.type_name().to_owned();
57        if !is_time_series_element_type(&element_type) {
58            return Err(powerio_core::Error::new(
59                &crate::codes::VALIDATE_COLLECTION_ELEMENT_TYPE,
60                format!("PowerIO IR does not define a time series of `{element_type}`"),
61            ));
62        }
63        require_element_type(values.iter(), &element_type)?;
64        let values = TimeSeries::new(time_points, values)?;
65        Ok(Self {
66            type_name: format!("powerio.TimeSeries<{element_type}>").into_boxed_str(),
67            element_type: element_type.into_boxed_str(),
68            values,
69        })
70    }
71
72    #[must_use]
73    pub fn element_type(&self) -> &str {
74        &self.element_type
75    }
76
77    #[must_use]
78    pub fn type_name(&self) -> &str {
79        &self.type_name
80    }
81
82    #[must_use]
83    pub fn time_points(&self) -> &[TimePoint] {
84        self.values.time_points()
85    }
86
87    #[must_use]
88    pub fn get(&self, index: usize) -> Option<&PioValue> {
89        self.values.get(index)
90    }
91
92    /// Mutably borrow one entry through the collection's copy on write
93    /// storage.
94    pub fn get_mut(&mut self, index: usize) -> Option<&mut PioValue> {
95        self.values.get_mut(index)
96    }
97
98    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&TimePoint, &PioValue)> {
99        self.values.iter()
100    }
101
102    /// Iterate over mutable entries through one copy on write split.
103    pub fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = (&TimePoint, &mut PioValue)> {
104        self.values.iter_mut()
105    }
106
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.values.len()
110    }
111
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.values.is_empty()
115    }
116
117    pub(crate) fn values(&self) -> &TimeSeries<PioValue> {
118        &self.values
119    }
120}
121
122/// A dynamically typed scenario set at the universal parser boundary.
123/// Typed Rust code uses `ScenarioSet<T>` directly.
124#[derive(Clone, Debug)]
125pub struct PioScenarioSet {
126    element_type: Box<str>,
127    type_name: Box<str>,
128    values: ScenarioSet<PioValue>,
129}
130
131impl PioScenarioSet {
132    fn from_typed<T>(element_type: &'static str, values: ScenarioSet<T>) -> Self
133    where
134        T: Clone + Into<PioValue>,
135    {
136        let values = values.map_values(Into::into);
137        debug_assert!(
138            values
139                .iter()
140                .all(|scenario| scenario.value().type_name() == element_type)
141        );
142        Self {
143            element_type: element_type.into(),
144            type_name: format!("powerio.ScenarioSet<{element_type}>").into_boxed_str(),
145            values,
146        }
147    }
148
149    /// Construct the dynamic form from scenarios that already crossed the
150    /// universal parser boundary.
151    ///
152    /// Typed Rust code should use [`ScenarioSet<T>`] and its ordinary
153    /// [`From`] conversion. An empty input has no value from which to infer
154    /// `T` and is rejected.
155    pub fn from_scenarios(scenarios: Vec<Scenario<PioValue>>) -> Result<Self, powerio_core::Error> {
156        let Some(first) = scenarios.first() else {
157            return Err(powerio_core::Error::new(
158                &crate::codes::VALIDATE_COLLECTION_EMPTY,
159                "a scenario set needs at least one value to infer its element type",
160            ));
161        };
162        let element_type = first.value().type_name().to_owned();
163        if !is_scenario_element_type(&element_type) {
164            return Err(powerio_core::Error::new(
165                &crate::codes::VALIDATE_COLLECTION_ELEMENT_TYPE,
166                format!("PowerIO IR does not define a scenario set of `{element_type}`"),
167            ));
168        }
169        require_element_type(scenarios.iter().map(Scenario::value), &element_type)?;
170        let values = ScenarioSet::new(scenarios)?;
171        Ok(Self {
172            type_name: format!("powerio.ScenarioSet<{element_type}>").into_boxed_str(),
173            element_type: element_type.into_boxed_str(),
174            values,
175        })
176    }
177
178    #[must_use]
179    pub fn element_type(&self) -> &str {
180        &self.element_type
181    }
182
183    #[must_use]
184    pub fn type_name(&self) -> &str {
185        &self.type_name
186    }
187
188    #[must_use]
189    pub fn get(&self, id: &str) -> Option<&PioValue> {
190        self.values.get(id)
191    }
192
193    /// Mutably borrow one entry by scenario ID through copy on write.
194    pub fn get_mut(&mut self, id: &str) -> Option<&mut PioValue> {
195        self.values.get_mut(id)
196    }
197
198    #[must_use]
199    pub fn get_at(&self, position: usize) -> Option<&PioValue> {
200        self.values.get_at(position)
201    }
202
203    /// Mutably borrow one entry by insertion position through copy on write.
204    pub fn get_at_mut(&mut self, position: usize) -> Option<&mut PioValue> {
205        self.values.get_at_mut(position)
206    }
207
208    pub fn iter(&self) -> impl ExactSizeIterator<Item = &Scenario<PioValue>> {
209        self.values.iter()
210    }
211
212    /// Iterate over mutable scenario entries through one copy on write split.
213    pub fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = &mut Scenario<PioValue>> {
214        self.values.iter_mut()
215    }
216
217    #[must_use]
218    pub fn len(&self) -> usize {
219        self.values.len()
220    }
221
222    #[must_use]
223    pub fn is_empty(&self) -> bool {
224        self.values.is_empty()
225    }
226
227    pub(crate) fn values(&self) -> &ScenarioSet<PioValue> {
228        &self.values
229    }
230}
231
232const BALANCED_NETWORK_TYPE: &str = "powerio.BalancedNetwork";
233const MULTICONDUCTOR_NETWORK_TYPE: &str = "powerio.MulticonductorNetwork";
234const BALANCED_OPERATING_POINT_TYPE: &str = "powerio.OperatingPoint<powerio.BalancedNetwork>";
235const MULTICONDUCTOR_OPERATING_POINT_TYPE: &str =
236    "powerio.OperatingPoint<powerio.MulticonductorNetwork>";
237
238fn is_time_series_element_type(type_name: &str) -> bool {
239    matches!(
240        type_name,
241        BALANCED_NETWORK_TYPE
242            | MULTICONDUCTOR_NETWORK_TYPE
243            | BALANCED_OPERATING_POINT_TYPE
244            | MULTICONDUCTOR_OPERATING_POINT_TYPE
245    )
246}
247
248fn is_scenario_element_type(type_name: &str) -> bool {
249    is_time_series_element_type(type_name)
250        || matches!(
251            type_name,
252            "powerio.TimeSeries<powerio.BalancedNetwork>"
253                | "powerio.TimeSeries<powerio.MulticonductorNetwork>"
254                | "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>"
255                | "powerio.TimeSeries<powerio.OperatingPoint<powerio.MulticonductorNetwork>>"
256        )
257}
258
259fn require_element_type<'a>(
260    values: impl IntoIterator<Item = &'a PioValue>,
261    element_type: &str,
262) -> Result<(), powerio_core::Error> {
263    if let Some(value) = values
264        .into_iter()
265        .find(|value| value.type_name() != element_type)
266    {
267        return Err(powerio_core::Error::new(
268            &crate::codes::VALIDATE_COLLECTION_ELEMENT_TYPE,
269            format!(
270                "collection starts with `{element_type}` but also contains `{}`",
271                value.type_name()
272            ),
273        ));
274    }
275    Ok(())
276}
277
278/// A value produced by PowerIO's universal parser or decoded from PowerIO IR.
279/// Application values remain ordinary `PioModule<T>` values.
280#[derive(Clone, Debug)]
281#[non_exhaustive]
282#[allow(clippy::large_enum_variant)]
283pub enum PioValue {
284    BalancedNetwork(BalancedNetwork),
285    MulticonductorNetwork(MulticonductorNetwork),
286    /// A standalone geographic document: element points and routes keyed by
287    /// element identity, placed onto a network by
288    /// [`crate::apply_geo_layer`].
289    GeoLayer(GeoLayer),
290    /// A PSS/E contingency description file (`.con`): the outages a
291    /// contingency analysis runs, bound to a network by
292    /// [`ContingencySet::resolve`].
293    ContingencySet(ContingencySet),
294    /// A PSS/E subsystem description file (`.sub`): the named bus groups a
295    /// contingency description file and a monitored element file draw on.
296    SubsystemSet(SubsystemSet),
297    /// A PSS/E monitored element file (`.mon`): the branch flows, interface
298    /// flows, and bus voltages a contingency analysis reports on.
299    MonitoredSet(MonitoredSet),
300    BalancedOperatingPoint(OperatingPoint<BalancedNetwork>),
301    MulticonductorOperatingPoint(OperatingPoint<MulticonductorNetwork>),
302    TimeSeries(PioTimeSeries),
303    ScenarioSet(PioScenarioSet),
304    DcPfInstance(powerio_prob::DcPfInstance),
305    AcPfInstance(powerio_prob::AcPfInstance),
306    DcOpfInstance(powerio_prob::DcOpfInstance),
307    AcOpfInstance(powerio_prob::AcOpfInstance),
308    McAcPfInstance(powerio_prob::McAcPfInstance),
309    McAcOpfInstance(powerio_prob::McAcOpfInstance),
310    LinDist3FlowOpfInstance(powerio_prob::LinDist3FlowOpfInstance),
311    AcScucInstance(powerio_prob::AcScucInstance),
312    DcPfSolution(powerio_prob::DcPfSolution),
313    AcPfSolution(powerio_prob::AcPfSolution),
314    DcOpfSolution(powerio_prob::DcOpfSolution),
315    AcOpfSolution(powerio_prob::AcOpfSolution),
316    SocwrOpfSolution(powerio_prob::solution::SocwrOpfSolution),
317    McAcPfSolution(powerio_prob::McAcPfSolution),
318    McAcOpfSolution(powerio_prob::McAcOpfSolution),
319    LinDist3FlowOpfSolution(powerio_prob::LinDist3FlowOpfSolution),
320    AcScucSolution(powerio_prob::AcScucSolution),
321}
322
323impl PioValue {
324    /// Canonical structural type name used by C and PowerIO IR.
325    #[must_use]
326    pub fn type_name(&self) -> &str {
327        match self {
328            Self::BalancedNetwork(_) => "powerio.BalancedNetwork",
329            Self::MulticonductorNetwork(_) => "powerio.MulticonductorNetwork",
330            Self::GeoLayer(_) => "powerio.GeoLayer",
331            Self::ContingencySet(_) => "powerio.ContingencySet",
332            Self::SubsystemSet(_) => "powerio.SubsystemSet",
333            Self::MonitoredSet(_) => "powerio.MonitoredSet",
334            Self::BalancedOperatingPoint(_) => "powerio.OperatingPoint<powerio.BalancedNetwork>",
335            Self::MulticonductorOperatingPoint(_) => {
336                "powerio.OperatingPoint<powerio.MulticonductorNetwork>"
337            }
338            Self::TimeSeries(series) => series.type_name(),
339            Self::ScenarioSet(scenarios) => scenarios.type_name(),
340            Self::DcPfInstance(_) => "powerio.DcPfInstance",
341            Self::AcPfInstance(_) => "powerio.AcPfInstance",
342            Self::DcOpfInstance(_) => "powerio.DcOpfInstance",
343            Self::AcOpfInstance(_) => "powerio.AcOpfInstance",
344            Self::McAcPfInstance(_) => "powerio.McAcPfInstance",
345            Self::McAcOpfInstance(_) => "powerio.McAcOpfInstance",
346            Self::LinDist3FlowOpfInstance(_) => "powerio.LinDist3FlowOpfInstance",
347            Self::AcScucInstance(_) => "powerio.AcScucInstance",
348            Self::DcPfSolution(_) => "powerio.DcPfSolution",
349            Self::AcPfSolution(_) => "powerio.AcPfSolution",
350            Self::DcOpfSolution(_) => "powerio.DcOpfSolution",
351            Self::AcOpfSolution(_) => "powerio.AcOpfSolution",
352            Self::SocwrOpfSolution(_) => "powerio.SocwrOpfSolution",
353            Self::McAcPfSolution(_) => "powerio.McAcPfSolution",
354            Self::McAcOpfSolution(_) => "powerio.McAcOpfSolution",
355            Self::LinDist3FlowOpfSolution(_) => "powerio.LinDist3FlowOpfSolution",
356            Self::AcScucSolution(_) => "powerio.AcScucSolution",
357        }
358    }
359}
360
361macro_rules! value_conversion {
362    ($ty:ty, $variant:ident) => {
363        impl From<$ty> for PioValue {
364            fn from(value: $ty) -> Self {
365                Self::$variant(value)
366            }
367        }
368    };
369}
370
371impl From<BalancedNetwork> for PioValue {
372    fn from(mut value: BalancedNetwork) -> Self {
373        value.assign_missing_component_ids();
374        Self::BalancedNetwork(value)
375    }
376}
377value_conversion!(MulticonductorNetwork, MulticonductorNetwork);
378value_conversion!(GeoLayer, GeoLayer);
379value_conversion!(ContingencySet, ContingencySet);
380value_conversion!(SubsystemSet, SubsystemSet);
381value_conversion!(MonitoredSet, MonitoredSet);
382value_conversion!(OperatingPoint<BalancedNetwork>, BalancedOperatingPoint);
383value_conversion!(
384    OperatingPoint<MulticonductorNetwork>,
385    MulticonductorOperatingPoint
386);
387value_conversion!(powerio_prob::DcPfInstance, DcPfInstance);
388value_conversion!(powerio_prob::AcPfInstance, AcPfInstance);
389value_conversion!(powerio_prob::DcOpfInstance, DcOpfInstance);
390value_conversion!(powerio_prob::AcOpfInstance, AcOpfInstance);
391value_conversion!(powerio_prob::McAcPfInstance, McAcPfInstance);
392value_conversion!(powerio_prob::McAcOpfInstance, McAcOpfInstance);
393value_conversion!(
394    powerio_prob::LinDist3FlowOpfInstance,
395    LinDist3FlowOpfInstance
396);
397value_conversion!(powerio_prob::AcScucInstance, AcScucInstance);
398value_conversion!(powerio_prob::DcPfSolution, DcPfSolution);
399value_conversion!(powerio_prob::AcPfSolution, AcPfSolution);
400value_conversion!(powerio_prob::DcOpfSolution, DcOpfSolution);
401value_conversion!(powerio_prob::AcOpfSolution, AcOpfSolution);
402value_conversion!(powerio_prob::solution::SocwrOpfSolution, SocwrOpfSolution);
403value_conversion!(powerio_prob::McAcPfSolution, McAcPfSolution);
404value_conversion!(powerio_prob::McAcOpfSolution, McAcOpfSolution);
405value_conversion!(
406    powerio_prob::LinDist3FlowOpfSolution,
407    LinDist3FlowOpfSolution
408);
409value_conversion!(powerio_prob::AcScucSolution, AcScucSolution);
410
411macro_rules! time_series_conversion {
412    ($ty:ty, $name:literal) => {
413        impl From<TimeSeries<$ty>> for PioValue {
414            fn from(value: TimeSeries<$ty>) -> Self {
415                Self::TimeSeries(PioTimeSeries::from_typed($name, value))
416            }
417        }
418    };
419}
420
421time_series_conversion!(BalancedNetwork, "powerio.BalancedNetwork");
422time_series_conversion!(MulticonductorNetwork, "powerio.MulticonductorNetwork");
423time_series_conversion!(
424    OperatingPoint<BalancedNetwork>,
425    "powerio.OperatingPoint<powerio.BalancedNetwork>"
426);
427time_series_conversion!(
428    OperatingPoint<MulticonductorNetwork>,
429    "powerio.OperatingPoint<powerio.MulticonductorNetwork>"
430);
431
432macro_rules! scenario_set_conversion {
433    ($ty:ty, $name:literal) => {
434        impl From<ScenarioSet<$ty>> for PioValue {
435            fn from(value: ScenarioSet<$ty>) -> Self {
436                Self::ScenarioSet(PioScenarioSet::from_typed($name, value))
437            }
438        }
439    };
440}
441
442scenario_set_conversion!(BalancedNetwork, "powerio.BalancedNetwork");
443scenario_set_conversion!(MulticonductorNetwork, "powerio.MulticonductorNetwork");
444scenario_set_conversion!(
445    OperatingPoint<BalancedNetwork>,
446    "powerio.OperatingPoint<powerio.BalancedNetwork>"
447);
448scenario_set_conversion!(
449    OperatingPoint<MulticonductorNetwork>,
450    "powerio.OperatingPoint<powerio.MulticonductorNetwork>"
451);
452scenario_set_conversion!(
453    TimeSeries<BalancedNetwork>,
454    "powerio.TimeSeries<powerio.BalancedNetwork>"
455);
456scenario_set_conversion!(
457    TimeSeries<MulticonductorNetwork>,
458    "powerio.TimeSeries<powerio.MulticonductorNetwork>"
459);
460scenario_set_conversion!(
461    TimeSeries<OperatingPoint<BalancedNetwork>>,
462    "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>"
463);
464scenario_set_conversion!(
465    TimeSeries<OperatingPoint<MulticonductorNetwork>>,
466    "powerio.TimeSeries<powerio.OperatingPoint<powerio.MulticonductorNetwork>>"
467);
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    fn small_balanced() -> BalancedNetwork {
474        use powerio_tx::{Bus, BusId, BusType};
475        BalancedNetwork::in_memory(
476            "facade",
477            100.0,
478            vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
479            Vec::new(),
480        )
481    }
482
483    #[test]
484    fn rust_uses_enum_matching_and_structural_names() {
485        let value = PioValue::from(small_balanced());
486        assert!(matches!(value, PioValue::BalancedNetwork(_)));
487        assert_eq!(value.type_name(), "powerio.BalancedNetwork");
488    }
489
490    #[test]
491    fn collections_keep_generic_structural_names() {
492        let series = TimeSeries::new(
493            vec![TimePoint::new("now", None).unwrap()],
494            vec![small_balanced()],
495        )
496        .unwrap();
497        let value = PioValue::from(series);
498        let PioValue::TimeSeries(series) = &value else {
499            unreachable!();
500        };
501        assert_eq!(series.len(), 1);
502        assert_eq!(
503            value.type_name(),
504            "powerio.TimeSeries<powerio.BalancedNetwork>"
505        );
506    }
507
508    #[test]
509    fn scenario_sets_compose_with_time_series() {
510        let series = TimeSeries::new(
511            vec![TimePoint::new("now", None).unwrap()],
512            vec![small_balanced()],
513        )
514        .unwrap();
515        let set = ScenarioSet::new(vec![Scenario::new(
516            powerio_core::ScenarioId::new("base").unwrap(),
517            None,
518            series,
519        )])
520        .unwrap();
521        let value = PioValue::from(set);
522        assert_eq!(
523            value.type_name(),
524            "powerio.ScenarioSet<powerio.TimeSeries<powerio.BalancedNetwork>>"
525        );
526    }
527}