powerio 0.11.7

Compiler infrastructure for power systems: parse, validate, transform, and emit grid models
Documentation
//! Facade format metadata.
//!
//! Component format enums select parsers and emitters inside their owning
//! crates. The facade exposes one small descriptor instead, so applications
//! can name an emitted artifact without depending on those implementation
//! enums or copying their alias tables.

use powerio_tx::format::routing::TransmissionFormat;

/// Electrical model family accepted by a grid format.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum NetworkFamily {
    Transmission,
    Distribution,
}

/// A discoverable grid format with its reader and writer capabilities.
#[derive(Clone, Copy, Debug, serde::Serialize)]
#[non_exhaustive]
pub struct GridFormatInfo {
    pub format: FormatInfo,
    pub label: &'static str,
    pub family: NetworkFamily,
    pub can_read: bool,
}

/// Enumerate supported grid exchange formats, including explicit output profiles.
///
/// PowerIO IR and geographic documents are separate operations. A format
/// behind a cargo feature, GridFM and the two SINCAL profiles, is listed only
/// when its implementation is compiled in.
///
/// # Panics
///
/// Panics if a catalog token has no matching format descriptor.
pub fn grid_formats() -> impl Iterator<Item = GridFormatInfo> {
    use NetworkFamily::{Distribution, Transmission};
    const FORMATS: &[(&str, &str, NetworkFamily, bool)] = &[
        ("matpower", "MATPOWER", Transmission, true),
        ("psse", "PSS/E RAW 33", Transmission, true),
        ("psse34", "PSS/E RAW 34", Transmission, true),
        ("psse35", "PSS/E RAW 35", Transmission, true),
        ("psse-rawx", "PSS/E RAWX", Transmission, true),
        ("powermodels-json", "PowerModels JSON", Transmission, true),
        ("pandapower-json", "pandapower JSON", Transmission, true),
        ("pypsa-csv", "PyPSA CSV folder", Transmission, true),
        ("egret-json", "Egret JSON", Transmission, true),
        ("powerworld", "PowerWorld AUX", Transmission, true),
        ("pslf", "PSLF EPC", Transmission, true),
        ("xiidm", "PowSybl XIIDM", Transmission, true),
        ("jiidm", "PowSybl JIIDM", Transmission, true),
        ("cgmes", "CIM CGMES", Transmission, true),
        ("ucte", "UCTE-DEF", Transmission, true),
        ("surge-json", "Surge JSON", Transmission, true),
        ("ieee-cdf", "IEEE CDF (read only)", Transmission, true),
        ("pwb", "PowerWorld PWB (read only)", Transmission, true),
        (
            "opfdata-json",
            "DeepMind OPFData (read only)",
            Transmission,
            true,
        ),
        ("goc3-json", "GO Challenge 3", Transmission, true),
        #[cfg(feature = "sincal")]
        ("sincal-balanced", "SINCAL balanced", Transmission, true),
        #[cfg(feature = "sincal")]
        (
            "sincal-multiconductor",
            "SINCAL multiconductor",
            Distribution,
            true,
        ),
        ("dss", "OpenDSS", Distribution, true),
        ("gridlabd", "GridLAB-D (PNNL GLM)", Distribution, true),
        (
            "gridlabd-arras",
            "GridLAB-D (Arras GLM)",
            Distribution,
            true,
        ),
        // The CIM release and application profile the reader and writer
        // implement: see `docs/src/distribution.md#distribution-cim`.
        (
            "cim-dist",
            "CIM distribution (CIM100 cim17v40, GridAPPS-D profile)",
            Distribution,
            true,
        ),
        ("pmd-json", "PMD JSON", Distribution, true),
        (
            "bmopf-json",
            "BMOPF JSON (detect version)",
            Distribution,
            true,
        ),
        ("bmopf-json@0.1.0", "BMOPF 0.1.0", Distribution, false),
        (
            "bmopf-json@0.2.0",
            "BMOPF 0.2.0 proposal",
            Distribution,
            false,
        ),
        #[cfg(feature = "gridfm")]
        ("gridfm", "GridFM Parquet", Transmission, true),
    ];
    FORMATS
        .iter()
        .map(|&(token, label, family, can_read)| GridFormatInfo {
            format: resolve_format(token).expect("catalog token has format metadata"),
            label,
            family,
            can_read,
        })
}

/// The canonical identity and destination shape of a PowerIO format.
///
/// `extension` is the conventional filename suffix without a leading dot; it
/// may be compound. It is `None` for directory formats with no primary case
/// file. `can_emit` reports whether a fresh universal emitter
/// exists for the format. It does not promise that every concrete module value can
/// emit that format, and it is not a build feature probe. A false value neither
/// promises nor forbids a same format retained source echo.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct FormatInfo {
    /// Canonical stable token used by parse and emit operations when the
    /// current build and concrete value support the format.
    pub token: &'static str,
    /// Conventional filename suffix without a leading dot.
    pub extension: Option<&'static str>,
    /// Whether a path destination names an output directory rather than one
    /// file.
    pub is_directory: bool,
    /// Whether a fresh universal emitter exists for this format.
    pub can_emit: bool,
}

const fn info(
    token: &'static str,
    extension: Option<&'static str>,
    is_directory: bool,
    can_emit: bool,
) -> FormatInfo {
    FormatInfo {
        token,
        extension,
        is_directory,
        can_emit,
    }
}

/// Resolve a format token or common alias to facade owned metadata.
///
/// This includes transmission and distribution grid exchange formats, the
/// standalone geographic layer document, and the three PSS/E contingency
/// analysis files. PowerIO IR is not a grid exchange format and therefore is
/// not returned here.
///
#[must_use]
pub fn resolve_format(name: &str) -> Option<FormatInfo> {
    if name.eq_ignore_ascii_case("sincal") {
        return Some(info("sincal", Some("sinx"), false, false));
    }
    if powerio_tx::format::routing::parse_distribution_format(name)
        == Some(powerio_tx::format::routing::DistributionFormat::SincalMulticonductor)
    {
        return Some(info("sincal-multiconductor", Some("sinx"), false, false));
    }
    match name {
        "bmopf-json@0.1.0" => return Some(info("bmopf-json@0.1.0", Some("json"), false, true)),
        "bmopf-json@0.2.0" => return Some(info("bmopf-json@0.2.0", Some("json"), false, true)),
        _ => {}
    }

    if crate::is_geo_layer_token(name) {
        return Some(info(
            "geo-json",
            Some(powerio_tx::geo::GEO_LAYER_EXTENSION),
            false,
            true,
        ));
    }
    if crate::is_pwd_display_token(name) {
        return Some(info("powerworld-pwd", Some("pwd"), false, false));
    }
    if let Some(kind) = crate::contingency_file_of_token(name) {
        return Some(info(kind.token(), Some(kind.extension()), false, true));
    }
    if let Some(format) = powerio_tx::format::parse_target_format(name) {
        let is_cgmes = format == powerio_tx::TargetFormat::Cgmes;
        return Some(info(
            format.token(),
            (!is_cgmes).then_some(format.extension()),
            is_cgmes,
            !matches!(format, powerio_tx::TargetFormat::DeepMindOpfDataJson),
        ));
    }

    if let Some(format) = powerio_dist::parse_dist_target_format(name) {
        return Some(match format.name() {
            "dss" => info("dss", Some("dss"), true, true),
            "gridlabd" => info("gridlabd", Some("glm"), true, true),
            "gridlabd-arras" => info("gridlabd-arras", Some("glm"), true, true),
            "pmd-json" => info("pmd-json", Some("json"), false, true),
            "bmopf-json" => info("bmopf-json", Some("json"), false, true),
            "cim-dist" => info("cim-dist", Some("xml"), false, true),
            _ => return None,
        });
    }

    match powerio_tx::format::routing::parse_transmission_format(name) {
        Some(TransmissionFormat::SincalBalanced) => {
            Some(info("sincal-balanced", Some("sinx"), false, false))
        }
        Some(TransmissionFormat::PypsaCsv) => Some(info("pypsa-csv", None, true, true)),
        Some(TransmissionFormat::Pwb) => Some(info("pwb", Some("pwb"), false, false)),
        Some(TransmissionFormat::Gridfm) => Some(info("gridfm", None, true, true)),
        // The public IEEE archives name their CDF cases `.txt`; the reader also
        // recognizes `.cdf` and any name with the declared format.
        Some(TransmissionFormat::IeeeCdf) => Some(info("ieee-cdf", Some("txt"), false, false)),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn aliases_resolve_to_one_canonical_descriptor() {
        assert_eq!(resolve_format("m"), resolve_format("MATPOWER"));
        assert_eq!(resolve_format("pm").unwrap().token, "powermodels-json");
        assert_eq!(resolve_format("engineering").unwrap().token, "pmd-json");
        assert_eq!(resolve_format("xiidm").unwrap().token, "xiidm");
        assert_eq!(resolve_format("jiidm").unwrap().token, "jiidm");
        assert_eq!(resolve_format("jiidm").unwrap().extension, Some("jiidm"));
        assert!(resolve_format("cgmes").unwrap().is_directory);
        assert_eq!(resolve_format("iidm"), None);
        assert_eq!(resolve_format("rawx"), None);
        assert_eq!(resolve_format("psse-rawx").unwrap().token, "psse-rawx");
        assert_eq!(resolve_format("con"), resolve_format("PSSE_CON"));
        assert_eq!(resolve_format("subsystem").unwrap().token, "psse-sub");
        assert_eq!(resolve_format("mon").unwrap().extension, Some("mon"));
    }

    #[test]
    fn destination_and_read_only_shapes_are_explicit() {
        let dss = resolve_format("opendss").unwrap();
        assert!(dss.is_directory);
        assert!(dss.can_emit);
        assert_eq!(dss.extension, Some("dss"));

        let pypsa = resolve_format("pypsa").unwrap();
        assert!(pypsa.is_directory);
        assert_eq!(pypsa.extension, None);

        let pwb = resolve_format("pwb").unwrap();
        assert!(!pwb.is_directory);
        assert!(!pwb.can_emit);
        assert_eq!(pwb.extension, Some("pwb"));

        let cdf = resolve_format("cdf").unwrap();
        assert_eq!(cdf.token, "ieee-cdf");
        assert!(!cdf.is_directory);
        assert!(!cdf.can_emit);
        assert_eq!(cdf.extension, Some("txt"));
    }

    #[test]
    fn catalog_lists_every_distribution_token_the_resolver_accepts() {
        // The distribution writers are the closed set the resolver maps by
        // name; each canonical token must be discoverable and round trip.
        for token in [
            "dss",
            "gridlabd",
            "gridlabd-arras",
            "pmd-json",
            "bmopf-json",
            "cim-dist",
        ] {
            let resolved = resolve_format(token).expect(token);
            let entry = grid_formats()
                .find(|entry| entry.format.token == token)
                .unwrap_or_else(|| panic!("{token} resolves but is not in the catalog"));
            assert_eq!(entry.format, resolved, "{token}");
            assert_eq!(entry.family, NetworkFamily::Distribution, "{token}");
            assert!(entry.can_read, "{token}");
        }
        // Aliases land on the canonical catalog entry.
        assert_eq!(resolve_format("glm").unwrap().token, "gridlabd");
        assert_eq!(resolve_format("gridlabd-pnnl").unwrap().token, "gridlabd");
    }

    #[test]
    fn distribution_cim_names_its_cim_release_and_profile() {
        let entry = grid_formats()
            .find(|entry| entry.format.token == "cim-dist")
            .unwrap();
        assert_eq!(
            entry.label,
            "CIM distribution (CIM100 cim17v40, GridAPPS-D profile)"
        );
    }

    #[test]
    fn nonformats_do_not_resolve() {
        assert_eq!(resolve_format("not-a-format"), None);
        assert_eq!(resolve_format("json"), None);
        assert_eq!(resolve_format("pio-json"), None);
    }
}