Skip to main content

powerio/
formats.rs

1//! Facade format metadata.
2//!
3//! Component format enums select parsers and emitters inside their owning
4//! crates. The facade exposes one small descriptor instead, so applications
5//! can name an emitted artifact without depending on those implementation
6//! enums or copying their alias tables.
7
8use powerio_tx::format::routing::TransmissionFormat;
9
10/// Electrical model family accepted by a grid format.
11#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
12#[serde(rename_all = "lowercase")]
13#[non_exhaustive]
14pub enum NetworkFamily {
15    Transmission,
16    Distribution,
17}
18
19/// A discoverable grid format with its reader and writer capabilities.
20#[derive(Clone, Copy, Debug, serde::Serialize)]
21#[non_exhaustive]
22pub struct GridFormatInfo {
23    pub format: FormatInfo,
24    pub label: &'static str,
25    /// The primary catalog grouping. Use [`Self::supports_family`] when
26    /// selecting formats for a network: one token may support both families.
27    pub family: NetworkFamily,
28    pub can_read: bool,
29}
30
31impl GridFormatInfo {
32    /// Whether a format profile accepts this electrical network family.
33    /// MG-RAVENS accepts both families under one token; its parsed value's
34    /// family follows the document's classes. Other catalog entries accept
35    /// their primary [`Self::family`].
36    #[must_use]
37    pub fn supports_family(&self, family: NetworkFamily) -> bool {
38        self.family == family
39            || (self.format.token == "ravens-json"
40                && matches!(
41                    family,
42                    NetworkFamily::Transmission | NetworkFamily::Distribution
43                ))
44    }
45}
46
47/// Enumerate supported grid exchange formats, including explicit output profiles.
48///
49/// PowerIO IR and geographic documents are separate operations. A format
50/// behind a cargo feature, GridFM and the two SINCAL profiles, is listed only
51/// when its implementation is compiled in.
52///
53/// # Panics
54///
55/// Panics if a catalog token has no matching format descriptor.
56pub fn grid_formats() -> impl Iterator<Item = GridFormatInfo> {
57    use NetworkFamily::{Distribution, Transmission};
58    const FORMATS: &[(&str, &str, NetworkFamily, bool)] = &[
59        ("matpower", "MATPOWER", Transmission, true),
60        ("psse", "PSS/E RAW 33", Transmission, true),
61        ("psse34", "PSS/E RAW 34", Transmission, true),
62        ("psse35", "PSS/E RAW 35", Transmission, true),
63        ("psse-rawx", "PSS/E RAWX", Transmission, true),
64        ("powermodels-json", "PowerModels JSON", Transmission, true),
65        ("pandapower-json", "pandapower JSON", Transmission, true),
66        ("pypsa-csv", "PyPSA CSV folder", Transmission, true),
67        ("egret-json", "Egret JSON", Transmission, true),
68        ("powerworld", "PowerWorld AUX", Transmission, true),
69        ("pslf", "PSLF EPC", Transmission, true),
70        ("xiidm", "PowSybl XIIDM", Transmission, true),
71        ("jiidm", "PowSybl JIIDM", Transmission, true),
72        ("cgmes", "CIM CGMES", Transmission, true),
73        ("ucte", "UCTE-DEF", Transmission, true),
74        ("surge-json", "Surge JSON", Transmission, true),
75        ("ieee-cdf", "IEEE CDF (read only)", Transmission, true),
76        ("pwb", "PowerWorld PWB (read only)", Transmission, true),
77        (
78            "opfdata-json",
79            "DeepMind OPFData (read only)",
80            Transmission,
81            true,
82        ),
83        ("goc3-json", "GO Challenge 3", Transmission, true),
84        #[cfg(feature = "sincal")]
85        ("sincal-balanced", "SINCAL balanced", Transmission, true),
86        #[cfg(feature = "sincal")]
87        (
88            "sincal-multiconductor",
89            "SINCAL multiconductor",
90            Distribution,
91            true,
92        ),
93        ("dss", "OpenDSS", Distribution, true),
94        ("gridlabd", "GridLAB-D (PNNL GLM)", Distribution, true),
95        (
96            "gridlabd-arras",
97            "GridLAB-D (Arras GLM)",
98            Distribution,
99            true,
100        ),
101        // The CIM release and application profile the reader and writer
102        // implement: see `docs/src/distribution.md#distribution-cim`.
103        (
104            "cim-dist",
105            "CIM distribution (CIM100 cim17v40, GridAPPS-D profile)",
106            Distribution,
107            true,
108        ),
109        ("pmd-json", "PMD JSON", Distribution, true),
110        ("pmd-math-json", "PMD MATHEMATICAL JSON", Distribution, true),
111        // One token for both MG-RAVENS profiles: a balanced document parses
112        // to a BalancedNetwork and a multiconductor one to a
113        // MulticonductorNetwork, by content.
114        (
115            "ravens-json",
116            "MG-RAVENS JSON (balanced and multiconductor profiles)",
117            Distribution,
118            true,
119        ),
120        (
121            "bmopf-json",
122            "BMOPF JSON (detect version)",
123            Distribution,
124            true,
125        ),
126        ("bmopf-json@0.1.0", "BMOPF 0.1.0", Distribution, false),
127        (
128            "bmopf-json@0.2.0",
129            "BMOPF 0.2.0 proposal",
130            Distribution,
131            false,
132        ),
133        #[cfg(feature = "gridfm")]
134        ("gridfm", "GridFM Parquet", Transmission, true),
135    ];
136    FORMATS
137        .iter()
138        .map(|&(token, label, family, can_read)| GridFormatInfo {
139            format: resolve_format(token).expect("catalog token has format metadata"),
140            label,
141            family,
142            can_read,
143        })
144}
145
146/// The canonical identity and destination shape of a PowerIO format.
147///
148/// `extension` is the conventional filename suffix without a leading dot; it
149/// may be compound. It is `None` for directory formats with no primary case
150/// file. `can_emit` reports whether a fresh universal emitter
151/// exists for the format. It does not promise that every concrete module value can
152/// emit that format, and it is not a build feature probe. A false value neither
153/// promises nor forbids a same format retained source echo.
154#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
155#[non_exhaustive]
156pub struct FormatInfo {
157    /// Canonical stable token used by parse and emit operations when the
158    /// current build and concrete value support the format.
159    pub token: &'static str,
160    /// Conventional filename suffix without a leading dot.
161    pub extension: Option<&'static str>,
162    /// Whether a path destination names an output directory rather than one
163    /// file.
164    pub is_directory: bool,
165    /// Whether a fresh universal emitter exists for this format.
166    pub can_emit: bool,
167}
168
169const fn info(
170    token: &'static str,
171    extension: Option<&'static str>,
172    is_directory: bool,
173    can_emit: bool,
174) -> FormatInfo {
175    FormatInfo {
176        token,
177        extension,
178        is_directory,
179        can_emit,
180    }
181}
182
183/// Resolve a format token or common alias to facade owned metadata.
184///
185/// This includes transmission and distribution grid exchange formats, the
186/// standalone geographic layer document, and the three PSS/E contingency
187/// analysis files. PowerIO IR is not a grid exchange format and therefore is
188/// not returned here.
189///
190#[must_use]
191pub fn resolve_format(name: &str) -> Option<FormatInfo> {
192    if name.eq_ignore_ascii_case("sincal") {
193        return Some(info("sincal", Some("sinx"), false, false));
194    }
195    if powerio_tx::format::routing::parse_distribution_format(name)
196        == Some(powerio_tx::format::routing::DistributionFormat::SincalMulticonductor)
197    {
198        return Some(info("sincal-multiconductor", Some("sinx"), false, false));
199    }
200    match name {
201        "bmopf-json@0.1.0" => return Some(info("bmopf-json@0.1.0", Some("json"), false, true)),
202        "bmopf-json@0.2.0" => return Some(info("bmopf-json@0.2.0", Some("json"), false, true)),
203        _ => {}
204    }
205
206    if crate::is_geo_layer_token(name) {
207        return Some(info(
208            "geo-json",
209            Some(powerio_tx::geo::GEO_LAYER_EXTENSION),
210            false,
211            true,
212        ));
213    }
214    if crate::is_pwd_display_token(name) {
215        return Some(info("powerworld-pwd", Some("pwd"), false, false));
216    }
217    if let Some(kind) = crate::contingency_file_of_token(name) {
218        return Some(info(kind.token(), Some(kind.extension()), false, true));
219    }
220    if let Some(format) = powerio_tx::format::parse_target_format(name) {
221        let is_cgmes = format == powerio_tx::TargetFormat::Cgmes;
222        return Some(info(
223            format.token(),
224            (!is_cgmes).then_some(format.extension()),
225            is_cgmes,
226            !matches!(format, powerio_tx::TargetFormat::DeepMindOpfDataJson),
227        ));
228    }
229
230    if let Some(format) = powerio_dist::parse_dist_target_format(name) {
231        return Some(match format.name() {
232            "dss" => info("dss", Some("dss"), true, true),
233            "gridlabd" => info("gridlabd", Some("glm"), true, true),
234            "gridlabd-arras" => info("gridlabd-arras", Some("glm"), true, true),
235            "pmd-json" => info("pmd-json", Some("json"), false, true),
236            "pmd-math-json" => info("pmd-math-json", Some("json"), false, true),
237            "bmopf-json" => info("bmopf-json", Some("json"), false, true),
238            "cim-dist" => info("cim-dist", Some("xml"), false, true),
239            _ => return None,
240        });
241    }
242
243    match powerio_tx::format::routing::parse_transmission_format(name) {
244        Some(TransmissionFormat::SincalBalanced) => {
245            Some(info("sincal-balanced", Some("sinx"), false, false))
246        }
247        Some(TransmissionFormat::PypsaCsv) => Some(info("pypsa-csv", None, true, true)),
248        Some(TransmissionFormat::Pwb) => Some(info("pwb", Some("pwb"), false, false)),
249        Some(TransmissionFormat::Gridfm) => Some(info("gridfm", None, true, true)),
250        // The public IEEE archives name their CDF cases `.txt`; the reader also
251        // recognizes `.cdf` and any name with the declared format.
252        Some(TransmissionFormat::IeeeCdf) => Some(info("ieee-cdf", Some("txt"), false, false)),
253        _ => None,
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn aliases_resolve_to_one_canonical_descriptor() {
263        assert_eq!(resolve_format("m"), resolve_format("MATPOWER"));
264        assert_eq!(resolve_format("pm").unwrap().token, "powermodels-json");
265        assert_eq!(resolve_format("engineering").unwrap().token, "pmd-json");
266        assert_eq!(resolve_format("xiidm").unwrap().token, "xiidm");
267        assert_eq!(resolve_format("jiidm").unwrap().token, "jiidm");
268        assert_eq!(resolve_format("jiidm").unwrap().extension, Some("jiidm"));
269        assert!(resolve_format("cgmes").unwrap().is_directory);
270        assert_eq!(resolve_format("iidm"), None);
271        assert_eq!(resolve_format("rawx"), None);
272        assert_eq!(resolve_format("psse-rawx").unwrap().token, "psse-rawx");
273        assert_eq!(resolve_format("con"), resolve_format("PSSE_CON"));
274        assert_eq!(resolve_format("subsystem").unwrap().token, "psse-sub");
275        assert_eq!(resolve_format("mon").unwrap().extension, Some("mon"));
276    }
277
278    #[test]
279    fn destination_and_read_only_shapes_are_explicit() {
280        let dss = resolve_format("opendss").unwrap();
281        assert!(dss.is_directory);
282        assert!(dss.can_emit);
283        assert_eq!(dss.extension, Some("dss"));
284
285        let pypsa = resolve_format("pypsa").unwrap();
286        assert!(pypsa.is_directory);
287        assert_eq!(pypsa.extension, None);
288
289        let pwb = resolve_format("pwb").unwrap();
290        assert!(!pwb.is_directory);
291        assert!(!pwb.can_emit);
292        assert_eq!(pwb.extension, Some("pwb"));
293
294        let cdf = resolve_format("cdf").unwrap();
295        assert_eq!(cdf.token, "ieee-cdf");
296        assert!(!cdf.is_directory);
297        assert!(!cdf.can_emit);
298        assert_eq!(cdf.extension, Some("txt"));
299    }
300
301    #[test]
302    fn catalog_lists_every_distribution_token_the_resolver_accepts() {
303        // The distribution writers are the closed set the resolver maps by
304        // name; each canonical token must be discoverable and round trip.
305        for token in [
306            "dss",
307            "gridlabd",
308            "gridlabd-arras",
309            "pmd-json",
310            "bmopf-json",
311            "cim-dist",
312        ] {
313            let resolved = resolve_format(token).expect(token);
314            let entry = grid_formats()
315                .find(|entry| entry.format.token == token)
316                .unwrap_or_else(|| panic!("{token} resolves but is not in the catalog"));
317            assert_eq!(entry.format, resolved, "{token}");
318            assert_eq!(entry.family, NetworkFamily::Distribution, "{token}");
319            assert!(entry.can_read, "{token}");
320        }
321        // Aliases land on the canonical catalog entry.
322        assert_eq!(resolve_format("glm").unwrap().token, "gridlabd");
323        assert_eq!(resolve_format("gridlabd-pnnl").unwrap().token, "gridlabd");
324    }
325
326    #[test]
327    fn distribution_cim_names_its_cim_release_and_profile() {
328        let entry = grid_formats()
329            .find(|entry| entry.format.token == "cim-dist")
330            .unwrap();
331        assert_eq!(
332            entry.label,
333            "CIM distribution (CIM100 cim17v40, GridAPPS-D profile)"
334        );
335    }
336
337    #[test]
338    fn catalog_family_capability_includes_both_ravens_profiles() {
339        for entry in grid_formats() {
340            assert!(
341                entry.supports_family(entry.family),
342                "{}",
343                entry.format.token
344            );
345            let other = match entry.family {
346                NetworkFamily::Transmission => NetworkFamily::Distribution,
347                NetworkFamily::Distribution => NetworkFamily::Transmission,
348            };
349            assert_eq!(
350                entry.supports_family(other),
351                entry.format.token == "ravens-json",
352                "{}",
353                entry.format.token
354            );
355        }
356        let balanced: Vec<_> = grid_formats()
357            .filter(|entry| entry.supports_family(NetworkFamily::Transmission))
358            .map(|entry| entry.format.token)
359            .collect();
360        assert!(balanced.contains(&"ravens-json"));
361        assert!(!balanced.contains(&"pmd-math-json"));
362    }
363
364    #[test]
365    fn nonformats_do_not_resolve() {
366        assert_eq!(resolve_format("not-a-format"), None);
367        assert_eq!(resolve_format("json"), None);
368        assert_eq!(resolve_format("pio-json"), None);
369    }
370}