automapper-validation 0.50.4

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Chapter 2 of the BDEW Codeliste der Verwendungszwecke: what a UTILMD
//! Strom FV2610 PIA+5 may send beside its OBIS as the purpose of its values
//! (C212 DE7140 with DE7143 `Z16`), per location and market role.
//!
//! The AHB conditions reading it — "Zulässig sind ausschließlich Codes aus
//! der Spalte „Code“ der Codeliste der Verwendungszwecke (Kapitel 2), sofern
//! in derselben Zeile sowohl in der Spalte „Lokation/…“ als auch in der
//! Spalte „Verwendbar für Marktrolle/…“ jeweils ein „X“ gesetzt ist und die
//! Aussagen der Spalte „Erläuterung“ zur im DE7140 genannten OBIS-Kennzahl
//! desselben PIA passen" — differ only in the location and the role:
//! [`location_and_role`].
//!
//! An Erläuterung names the OBIS a code is for by its Messart (Wertegruppe D:
//! 29 Lastgang, 9 Vorschub, 6 Maximum) and by the Lieferrichtung, which the
//! OBIS's Messgröße (Wertegruppe C) gives: 1 Bezug (Verbrauch), 2 Lieferung
//! (Erzeugung).
//!
//! Data source: the Codeliste's .docx on bdew-mako.de, extracted with
//! `scripts/extract_verwendungszwecke.py` (version and file id in the JSON).

use serde::Deserialize;
use std::sync::OnceLock;

use super::{ConditionResult, EvaluationContext};
use crate::eval::format_validators::parse_obis;

const EMBEDDED_JSON: &str = include_str!("../../data/verwendungszwecke.json");

/// One code of the list.
#[derive(Debug, Clone, Deserialize)]
pub struct Verwendungszweck {
    pub code: String,
    pub name: String,
    /// `MaLo`, `Tranche`, `NeLo`.
    pub locations: Vec<String>,
    /// `NB`, `LF`, `ÜNB`.
    pub roles: Vec<String>,
    #[serde(default)]
    pub explanation: Option<String>,
    /// The OBIS Messarten (Wertegruppe D) the code is for; empty: any.
    #[serde(default)]
    pub obis_messarten: Vec<u32>,
    /// `Verbrauch` or `Erzeugung`; `None`: either.
    #[serde(default)]
    pub lieferrichtung: Option<String>,
}

#[derive(Debug, Deserialize)]
struct Table {
    codes: Vec<Verwendungszweck>,
}

/// The codes of the embedded list.
pub fn codes() -> &'static [Verwendungszweck] {
    static TABLE: OnceLock<Vec<Verwendungszweck>> = OnceLock::new();
    TABLE.get_or_init(|| {
        serde_json::from_str::<Table>(EMBEDDED_JSON)
            .expect("embedded verwendungszwecke.json")
            .codes
    })
}

/// The location and the market role a UTILMD Strom FV2610 condition names
/// (`[381]` "Lokation/MaLo" and "Verwendbar für Marktrolle/NB"); `None` for
/// any other condition.
pub fn location_and_role(condition: u32) -> Option<(&'static str, &'static str)> {
    Some(match condition {
        185 | 381 => ("MaLo", "NB"),
        186 | 382 => ("MaLo", "LF"),
        187 | 383 => ("MaLo", "ÜNB"),
        274 | 422 => ("Tranche", "NB"),
        275 | 423 => ("Tranche", "LF"),
        276 | 424 => ("Tranche", "ÜNB"),
        353 => ("NeLo", "NB"),
        354 => ("NeLo", "LF"),
        _ => return None,
    })
}

/// Whether `condition` judges the code by the OBIS in DE7140. `[185]`–`[187]`
/// and `[274]`–`[276]` (SEQ+Z27 / Z16, Messprodukte) name a Produkt-Code
/// there ("zu dem im DE7140 genannten Produkt-Code"), which says nothing
/// about a Messart or a Lieferrichtung.
pub fn reads_obis(condition: u32) -> bool {
    !matches!(condition, 185..=187 | 274..=276)
}

/// Whether the code `value` may be sent for `location` to `role` beside the
/// OBIS `obis` of the same PIA. Without an OBIS (`None`) only location and
/// role are judged: the missing OBIS is its own finding. A Messgröße that
/// names no Lieferrichtung (Blindarbeit) is not held against the code.
///
/// An OBIS that does not parse fits no code the Erläuterung ties to a
/// Messart or a Lieferrichtung: V11 beside `93PF5G8LJ8PQ` says nothing about
/// a Lastgang, and once let the sample generator make V11 fit by replacing a
/// Vorschub OBIS with a placeholder.
pub fn allows(value: &str, location: &str, role: &str, obis: Option<&str>) -> bool {
    let Some(entry) = codes().iter().find(|c| c.code == value) else {
        return false;
    };
    if !entry.locations.iter().any(|l| l == location) || !entry.roles.iter().any(|r| r == role) {
        return false;
    }
    let Some(obis) = obis else {
        return true;
    };
    let Some((c, d)) = parse_obis(obis) else {
        return entry.obis_messarten.is_empty() && entry.lieferrichtung.is_none();
    };
    if !entry.obis_messarten.is_empty() && !entry.obis_messarten.contains(&d) {
        return false;
    }
    !matches!(
        (entry.lieferrichtung.as_deref(), c),
        (Some("Verbrauch"), 2) | (Some("Erzeugung"), 1)
    )
}

/// UTILMD Strom FV2610 `condition` on the PIA code in hand: [`allows`] for
/// the location and role it names, with the OBIS of the same PIA (its first
/// C212).
///
/// The condition restricts the value of an element the AHB marks `X`
/// (`X [353]`): it always applies, so without a code it holds and the
/// element is required. Unknown there, an empty slot (`PIA+…+::Z16`) passed
/// as not required.
pub fn evaluate(condition: u32, ctx: &EvaluationContext) -> ConditionResult {
    let Some((location, role)) = location_and_role(condition) else {
        return ConditionResult::Unknown;
    };
    let Some(value) = ctx.resolved_value.filter(|v| !v.is_empty()) else {
        return ConditionResult::True;
    };
    let obis = ctx
        .resolved_segment
        .filter(|_| reads_obis(condition))
        .and_then(|elements| elements.get(1))
        .and_then(|c212| c212.first())
        .map(String::as_str)
        .filter(|v| !v.is_empty());
    ConditionResult::from(allows(value, location, role, obis))
}

/// The codes `condition` allows for some OBIS, for a sample generator to
/// offer the element it judges. Empty for any other condition, message type
/// or format version.
pub fn candidate_values(message_type: &str, fv: &str, condition: u32) -> Vec<String> {
    if message_type != "UTILMD" || fv != "FV2610" {
        return Vec::new();
    }
    let Some((location, role)) = location_and_role(condition) else {
        return Vec::new();
    };
    codes()
        .iter()
        .filter(|c| c.locations.iter().any(|l| l == location) && c.roles.iter().any(|r| r == role))
        .map(|c| c.code.clone())
        .collect()
}

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

    #[test]
    fn the_list_loads() {
        assert_eq!(codes().len(), 19);
        let v16 = codes().iter().find(|c| c.code == "V16").unwrap();
        // The correction of 29.06.2026: V16 may go to the ÜNB.
        assert_eq!(v16.roles, ["ÜNB"]);
    }

    #[test]
    fn a_code_is_judged_by_location_role_and_obis() {
        // V01 Netznutzungsabrechnung: MaLo, NB/LF, Lieferrichtung Verbrauch.
        assert!(allows("V01", "MaLo", "NB", Some("1-0:1.8.0")));
        assert!(!allows("V01", "MaLo", "ÜNB", Some("1-0:1.8.0")));
        assert!(!allows("V01", "Tranche", "NB", Some("1-0:1.8.0")));
        assert!(!allows("V01", "MaLo", "NB", Some("1-0:2.8.0")));
        // V16 Ausgeglichenheit von Bilanzkreisen: a Lastgang only (D 29).
        assert!(allows("V16", "MaLo", "ÜNB", Some("1-1:1.29.0")));
        assert!(!allows("V16", "MaLo", "ÜNB", Some("1-0:1.8.0")));
        // V05: Vorschub or Maximum.
        assert!(allows("V05", "MaLo", "LF", Some("1-0:1.9.1")));
        assert!(allows("V05", "MaLo", "LF", Some("1-0:1.6.0")));
        assert!(!allows("V05", "MaLo", "LF", Some("1-0:1.29.0")));
        // A placeholder is no code.
        assert!(!allows("5XL", "MaLo", "LF", None));
        // A placeholder OBIS fits no code tied to a Messart or Lieferrichtung,
        // only one that is not.
        assert!(!allows("V11", "MaLo", "ÜNB", Some("93PF5G8LJ8PQ")));
        assert!(!allows("V01", "MaLo", "NB", Some("93PF5G8LJ8PQ")));
        assert!(allows("V15", "MaLo", "ÜNB", Some("93PF5G8LJ8PQ")));
        assert!(allows("V12", "NeLo", "NB", Some("YCRVWZN1L572")));
        // [185] reads a Messprodukt-Code in DE7140, no OBIS.
        assert!(!reads_obis(185) && !reads_obis(276) && reads_obis(381) && reads_obis(353));
    }

    #[test]
    fn candidates_follow_location_and_role() {
        let lf_tranche = candidate_values("UTILMD", "FV2610", 423);
        assert!(lf_tranche.contains(&"V07".to_string()));
        assert!(!lf_tranche.contains(&"V01".to_string()));
        assert!(candidate_values("UTILMD", "FV2604", 423).is_empty());
    }
}