mako-markt 0.20.0

Market data library for German energy market (MaKo/marktd)
Documentation
#![allow(clippy::doc_markdown)]
//! Domain identifier types and shared enums.
//!
//! Identifier types (`MaloId`, `MeloId`, `MarktpartnerId`) are
//! re-exported from [`rubo4e::identifiers`] — validated at construction time
//! via `TryFrom` / `FromStr`.  No hand-rolled validation lives here.
//!
//! # Marktpartner-ID vs. GLN
//!
//! In BO4E and the BDEW *Allgemeine Festlegungen*, the correct term for a
//! 13-digit market participant identifier is **`MarktpartnerId`**
//! (German: *Rollencodenummer*).  Three coding authorities issue these IDs:
//!
//! | Prefix | Authority | NAD DE3055 | UNB DE0007 |
//! |--------|-----------|------------|------------|
//! | `99…`  | BDEW-Codenummer (Strom) | `293` | `500` |
//! | `98…`  | DVGW-Codenummer (Gas)  | `332` | `502` |
//! | other  | GS1 **GLN**            | `9`   | `14`  |
//!
//! **Only** the GS1-issued identifiers are true GLNs.  BDEW and DVGW codes are
//! *not* GLNs.  Using "GLN" as a generic alias is therefore misleading;
//! use `MarktpartnerId` for the type and [`nad_agency_code()`] to resolve the
//! correct coding authority at EDIFACT encoding time.

use serde::{Deserialize, Serialize};

// ── Identifier re-exports ─────────────────────────────────────────────────────

pub use rubo4e::identifiers::{
    EicCode, MaloId, MarktpartnerId, MeloId, NeloId, ObisCode, SrId, TrId,
};

/// Derive the NAD DE3055 agency code from a `MarktpartnerId`.
///
/// | Prefix | Agency code | Standard |
/// |--------|-------------|----------|
/// | `99`   | `"293"` | BDEW-Codenummer Strom (NAD DE3055) |
/// | `98`   | `"332"` | DVGW-Codenummer Gas (NAD DE3055) |
/// | other 13-digit | `"9"` | GS1 GLN (NAD DE3055) |
///
/// **Note:** NAD DE3055 and UNB DE0007 use different values for the same
/// authority (`9` vs. `14` for GS1; `293` vs. `500` for BDEW).
/// This function returns NAD DE3055 values only.
/// Use [`MarktpartnerId::unb_agency_code`] for UNB DE0007.
///
/// This is a thin wrapper around [`MarktpartnerId::nad_agency_code`],
/// kept for call-site compatibility.  Prefer calling the method directly.
#[must_use]
#[inline]
pub fn nad_agency_code(id: &MarktpartnerId) -> &'static str {
    id.nad_agency_code()
}

// ── Sparte ───────────────────────────────────────────────────────────────────

/// Energy commodity type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Sparte {
    Strom,
    Gas,
}

impl std::fmt::Display for Sparte {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Strom => write!(f, "STROM"),
            Self::Gas => write!(f, "GAS"),
        }
    }
}

impl std::str::FromStr for Sparte {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_uppercase().as_str() {
            "STROM" => Ok(Self::Strom),
            "GAS" => Ok(Self::Gas),
            other => Err(format!("unknown Sparte '{other}'; expected STROM or GAS")),
        }
    }
}

// ── Lokationstyp ─────────────────────────────────────────────────────────────

/// The node type of an edge in the Lokationszuordnung graph.
///
/// | Variant | Wire value | Meaning |
/// |---|---|---|
/// | `Malo` | `MALO` | Marktlokation |
/// | `Melo` | `MELO` | Messlokation |
/// | `Nelo` | `NELO` | Netzlokation |
/// | `Sr` | `SR` | Steuerbare Ressource |
/// | `Tr` | `TR` | Technische Ressource |
///
/// # Why this is mako's and not BO4E's
///
/// This was `rubo4e::current::Lokationstyp` until BO4E **removed the enum** in
/// schema release v202607.1.0. It could be removed because it was already
/// unreferenced by every BO and COM in the release before it — BO4E defines the
/// *locations* but no object that types an edge between two of them, which is
/// exactly what mako's graph needs. So the concept is mako's, and keeping it
/// here stops a schema release from deleting a column's domain again.
///
/// The wire values are unchanged, so the persisted `TEXT` columns and their
/// `CHECK (von_typ IN ('MALO', 'MELO', 'NELO', 'SR', 'TR'))` constraints need no
/// migration.
///
/// **Not to be confused with `edi_energy::Lokationstyp`**, which is the UTILMD
/// `SG5 LOC` DE 3227 *qualifier* set (`Z16`–`Z22`). That one carries seven
/// values, spells them as wire qualifiers, and includes `RuhendeMarktlokation`,
/// which is a state of a Marktlokation rather than a node type. The two are
/// deliberately separate: one names a position in mako's graph, the other names
/// a code on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Lokationstyp {
    /// Marktlokation.
    Malo,
    /// Messlokation.
    Melo,
    /// Netzlokation.
    Nelo,
    /// Steuerbare Ressource.
    Sr,
    /// Technische Ressource.
    Tr,
}

impl Lokationstyp {
    /// Every variant, in declaration order — the source of truth for the SQL
    /// `CHECK` lists on `von_typ` / `nach_typ`.
    pub const VARIANTS: &'static [&'static str] = &["MALO", "MELO", "NELO", "SR", "TR"];

    /// The wire value, as stored in the `TEXT` column.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Malo => "MALO",
            Self::Melo => "MELO",
            Self::Nelo => "NELO",
            Self::Sr => "SR",
            Self::Tr => "TR",
        }
    }
}

impl From<Lokationstyp> for &'static str {
    fn from(t: Lokationstyp) -> Self {
        t.as_str()
    }
}

impl std::fmt::Display for Lokationstyp {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl std::str::FromStr for Lokationstyp {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_uppercase().as_str() {
            "MALO" => Ok(Self::Malo),
            "MELO" => Ok(Self::Melo),
            "NELO" => Ok(Self::Nelo),
            "SR" => Ok(Self::Sr),
            "TR" => Ok(Self::Tr),
            other => Err(format!(
                "unknown Lokationstyp '{other}'; expected one of {:?}",
                Self::VARIANTS
            )),
        }
    }
}

// ── ProcessStatus ─────────────────────────────────────────────────────────────

/// Status of a correlated MaKo process.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProcessStatus {
    Running,
    Completed,
    Failed,
}

impl std::fmt::Display for ProcessStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Running => write!(f, "RUNNING"),
            Self::Completed => write!(f, "COMPLETED"),
            Self::Failed => write!(f, "FAILED"),
        }
    }
}

impl std::str::FromStr for ProcessStatus {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "RUNNING" => Ok(Self::Running),
            "COMPLETED" => Ok(Self::Completed),
            "FAILED" => Ok(Self::Failed),
            other => Err(format!("unknown process status: {other}")),
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    #[test]
    fn malo_id_valid() {
        // Valid under the BDEW Anwendungshilfe check digit.
        assert!("51238696012".parse::<MaloId>().is_ok());
    }

    #[test]
    fn malo_id_wrong_checksum() {
        // `…782` fails whichever check-digit scheme the identifier crate
        // applies, so this asserts "a wrong check digit is refused" rather
        // than pinning one implementation's arithmetic.
        assert!("51238696782".parse::<MaloId>().is_err());
    }

    #[test]
    fn malo_id_too_short() {
        assert!("1234567890".parse::<MaloId>().is_err());
    }

    #[test]
    fn melo_id_valid() {
        let id = "DE0001234567890123456789012345678";
        assert!(id.parse::<MeloId>().is_ok());
    }

    #[test]
    fn melo_id_invalid_prefix() {
        // rubo4e::MeloId accepts any ISO 3166-1 alpha-2 prefix (BO4E is international).
        // Truly invalid: starts with a digit (not uppercase ASCII letter).
        assert!(
            "1X0001234567890123456789012345678"
                .parse::<MeloId>()
                .is_err()
        );
    }

    #[test]
    fn melo_id_invalid_prefix_lowercase() {
        // Lowercase country codes are also invalid — must be uppercase ASCII letters.
        assert!(
            "de0001234567890123456789012345678"
                .parse::<MeloId>()
                .is_err()
        );
    }

    #[test]
    fn melo_id_invalid_length() {
        assert!("DE00012345678".parse::<MeloId>().is_err());
    }

    #[test]
    fn melo_id_non_de_prefix_valid() {
        // rubo4e follows the BO4E spec — any ISO 3166-1 alpha-2 country code is valid,
        // not just DE.  This documents the intentional break from the previous
        // hand-rolled parser that was restricted to "DE" only.
        assert!(
            "AT0001234567890123456789012345678"
                .parse::<MeloId>()
                .is_ok()
        );
    }

    #[test]
    fn marktpartner_id_gs1_gln() {
        // GS1 GLN: 13-digit not starting with 98/99 → NAD DE3055 agency code "9"
        let id: MarktpartnerId = "1234567890128".parse().unwrap();
        assert_eq!(nad_agency_code(&id), "9");
    }

    #[test]
    fn marktpartner_id_bdew() {
        // BDEW-Codenummer Strom (prefix 99) → NAD DE3055 agency code "293"
        let id: MarktpartnerId = "9900357000004".parse().unwrap();
        assert_eq!(nad_agency_code(&id), "293");
    }

    #[test]
    fn marktpartner_id_dvgw() {
        // DVGW-Codenummer Gas (prefix 98) → NAD DE3055 agency code "332"
        let id: MarktpartnerId = "9800001000003".parse().unwrap();
        assert_eq!(nad_agency_code(&id), "332");
    }

    #[test]
    fn marktpartner_id_invalid_length() {
        assert!("123".parse::<MarktpartnerId>().is_err());
    }
}