edifact-mapper 0.16.1

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! Each BO4E entity element is evaluated in the group instance it becomes.
//!
//! mako-twin's 55043: two OBIS groups without a Smartmeter-Gateway, and a
//! third meter in another SG8 that names one (`RFF+Z14`). `konfigurationsId`
//! is `X [402] ∧ [420]`, [420] = "RFF+Z14 in dieser SG8": each OBIS group
//! must be judged by its own SG8, not by the third meter's.

use automapper_validation::{
    ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
    MapExternalProvider,
};
use edifact_mapper::element_scopes::entity_element_scopes;
use edifact_mapper::{
    validate_with_conditions, validate_with_conditions_in_scopes, DataDir, Mapper,
};
use mig_assembly::assembler::{AssembledGroup, AssembledTree};
use mig_assembly::navigator::AssembledTreeNavigator;
use mig_types::segment::OwnedSegment;
use serde_json::Value;
use std::path::PathBuf;

fn setup(fv: &str) -> Option<(Mapper, Value)> {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
    if !root.join(format!("dist/edifact-data-{fv}.bin")).exists() {
        eprintln!("skipping: build the {fv} bundle first");
        return None;
    }
    let mapper =
        Mapper::from_data_dir(DataDir::path(root.join("dist")).eager(&[fv])).expect("bundle");
    let dump: Value = serde_json::from_str(
        &std::fs::read_to_string(
            root.join("crates/edifact-mapper/tests/data/mako_twin_55043_zaehler_ohne_smgw.json"),
        )
        .expect("dump"),
    )
    .expect("json");
    Some((mapper, dump[fv].clone()))
}

#[test]
fn every_element_is_scoped_to_its_own_instance() {
    for fv in ["FV2604", "FV2610"] {
        let Some((mapper, json)) = setup(fv) else {
            continue;
        };
        let engine = mapper.engine(fv, "UTILMD_Strom", "55043").expect("engine");
        let tree = engine.map_all_reverse(&json, None);
        let scopes = entity_element_scopes(&engine, &json, &tree);

        let scope = |key: &str, index: usize| {
            scopes
                .get(&(key.to_string(), Some(index)))
                .cloned()
                .unwrap_or_else(|| panic!("{fv}: no scope for {key}[{index}]"))
        };
        let obis = [
            scope("obisZaehleinrichtung", 0),
            scope("obisZaehleinrichtung", 1),
        ];
        let meter_with_smgw = scope("zaehlerDaten", 2);
        for path in obis.iter().chain([&meter_with_smgw]) {
            let groups: Vec<&str> = path.iter().map(|(g, _)| g.as_str()).collect();
            assert_eq!(groups, ["SG4", "SG8"], "{fv}: {path:?}");
        }
        assert_ne!(obis[0], obis[1], "{fv}");
        assert!(!obis.contains(&meter_with_smgw), "{fv}");

        // The meter's instance is the one holding its RFF+Z14.
        let sg8 = &tree
            .groups
            .iter()
            .find(|g| g.group_id == "SG4")
            .unwrap()
            .repetitions[meter_with_smgw[0].1]
            .child_groups
            .iter()
            .find(|g| g.group_id == "SG8")
            .unwrap()
            .repetitions[meter_with_smgw[1].1];
        assert!(
            sg8.segments
                .iter()
                .any(|s| s.tag == "RFF" && s.elements[0][0] == "Z14"),
            "{fv}"
        );

        // A single object (not an array) is keyed without an index.
        assert!(
            json["prozessdaten"].is_object()
                == scopes.contains_key(&("prozessdaten".to_string(), None)),
            "{fv}"
        );
    }
}

/// [420] as the AHB words it — "RFF+Z14 in dieser SG8" — and every other
/// condition true, so only the scope decides.
struct Only420Scoped;

impl ConditionEvaluator for Only420Scoped {
    fn evaluate(&self, condition: u32, ctx: &EvaluationContext) -> ConditionResult {
        if condition != 420 {
            return ConditionResult::True;
        }
        ConditionResult::from(
            ctx.scoped_find_segments_in("SG8", "RFF")
                .iter()
                .any(|s| s.get_element(0) == "Z14"),
        )
    }
    fn is_external(&self, _: u32) -> bool {
        false
    }
    fn message_type(&self) -> &str {
        "UTILMD"
    }
    fn format_version(&self) -> &str {
        "FV2604"
    }
}

fn flat(tree: &AssembledTree) -> Vec<OwnedSegment> {
    fn walk(groups: &[AssembledGroup], out: &mut Vec<OwnedSegment>) {
        for g in groups {
            for r in &g.repetitions {
                for s in &r.segments {
                    out.push(OwnedSegment {
                        id: s.tag.clone(),
                        elements: s.elements.clone(),
                        segment_number: out.len() as u32,
                    });
                }
                walk(&r.child_groups, out);
            }
        }
    }
    let mut out = Vec::new();
    walk(&tree.groups, &mut out);
    out
}

#[test]
fn an_obis_group_is_judged_by_its_own_sg8() {
    for fv in ["FV2604", "FV2610"] {
        let Some((mapper, json)) = setup(fv) else {
            continue;
        };
        let engine = mapper.engine(fv, "UTILMD_Strom", "55043").expect("engine");
        let requirements = mapper
            .pid_requirements(fv, "UTILMD_Strom", "55043")
            .expect("requirements");
        let tree = engine.map_all_reverse(&json, None);
        let segments = flat(&tree);
        let navigator = AssembledTreeNavigator::new(&tree);
        let external = MapExternalProvider::new(Default::default());
        let ctx = EvaluationContext::with_navigator("55043", &external, &segments, &navigator);
        let evaluator = Only420Scoped;
        let expr_eval = ConditionExprEvaluator::new(&evaluator);
        let konfiguration = |errors: Vec<edifact_mapper::PidValidationError>| -> usize {
            errors
                .iter()
                .filter(|e| {
                    e.to_string()
                        .contains("ObisZaehleinrichtung.konfigurationsId")
                })
                .count()
        };

        // Unscoped, [420] sees the third meter's RFF+Z14: both OBIS groups
        // are asked for a konfigurationsId.
        assert_eq!(
            konfiguration(validate_with_conditions(
                &json,
                &requirements,
                &expr_eval,
                &ctx
            )),
            2,
            "{fv}"
        );
        // Each in its own SG8: neither has an SMGW, neither is asked.
        let scopes = entity_element_scopes(&engine, &json, &tree);
        assert_eq!(
            konfiguration(validate_with_conditions_in_scopes(
                &json,
                &requirements,
                &expr_eval,
                &ctx,
                &scopes
            )),
            0,
            "{fv}"
        );
    }
}