automapper-validation 0.30.2

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! UTILMD Strom conditions that compare *this* SG8 (or SG6) with another group
//! instance of the same SG4: "wie ein RFF+Z18 aus einer SG8 SEQ+Z01", "in einem
//! weiteren SG8", "mit identischer Zeitraum-ID wie in dieser SG8", "SG5 LOC+Z22
//! mit derselben Zeitraum-ID". Each used to ask whether *some* instance of the
//! message matched, so every SG8 got the same answer.
//!
//! One SG4 holds three MaLos, their SG6s and SG8s; each condition is asked from
//! instances where the answer differs.

use std::collections::BTreeMap;

use automapper_validation::eval::{
    ConditionEvaluator, ConditionResult, EvaluationContext, GroupScope, MapExternalProvider,
};
use automapper_validation::{
    UtilmdStromConditionEvaluatorFV2504, UtilmdStromConditionEvaluatorFV2604,
};
use mig_assembly::assembler::{
    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
};
use mig_assembly::navigator::AssembledTreeNavigator;
use mig_types::segment::OwnedSegment;

use ConditionResult::{False, True};

fn seg(tag: &str, elements: &[&[&str]]) -> AssembledSegment {
    AssembledSegment {
        tag: tag.to_string(),
        elements: elements
            .iter()
            .map(|e| e.iter().map(|c| c.to_string()).collect())
            .collect(),
        mig_number: None,
        segment_number: None,
    }
}

fn instance(
    segments: Vec<AssembledSegment>,
    child_groups: Vec<AssembledGroup>,
) -> AssembledGroupInstance {
    AssembledGroupInstance {
        segments,
        child_groups,
        entry_mig_number: None,
        variant_mig_numbers: vec![],
        skipped_segments: vec![],
        skipped_positions: Vec::new(),
    }
}

fn group(id: &str, repetitions: Vec<AssembledGroupInstance>) -> AssembledGroup {
    AssembledGroup {
        group_id: id.to_string(),
        repetitions,
    }
}

/// An SG8 with its SEQ (code, Zeitraum-ID), further segments and SG10s.
fn sg8(
    seq: &str,
    zeitraum: &str,
    mut segments: Vec<AssembledSegment>,
    sg10s: Vec<Vec<AssembledSegment>>,
) -> AssembledGroupInstance {
    segments.insert(0, seg("SEQ", &[&[seq], &[zeitraum]]));
    let children = if sg10s.is_empty() {
        vec![]
    } else {
        vec![group(
            "SG10",
            sg10s.into_iter().map(|s| instance(s, vec![])).collect(),
        )]
    };
    instance(segments, children)
}

fn rff(qualifier: &str, value: &str) -> AssembledSegment {
    seg("RFF", &[&[qualifier, value]])
}

fn cci(klassentyp: &str, code: &str) -> AssembledSegment {
    seg("CCI", &[&[klassentyp], &[], &[code]])
}

fn cav(code: &str) -> AssembledSegment {
    seg("CAV", &[&[code]])
}

/// SG8 indices, for reading the assertions.
const Z01_MALO1: usize = 0;
const PROFIL_MALO1: usize = 4;
const PROFIL_MALO2: usize = 5;
const Z58_T1: usize = 6;
const Z58_T1_SAME: usize = 7;
const Z58_T2: usize = 8;
const Z20_DEV1: usize = 9;
const Z20_DEV2: usize = 11;
const Z98_PAUSCHAL: usize = 12;
const Z98_T2: usize = 14;

fn tree() -> AssembledTree {
    let loc = |q: &str, id: &str, zeitraum: &str| seg("LOC", &[&[q], &[id, "", "", zeitraum]]);
    let sg5s = vec![
        instance(vec![loc("Z16", "MALO1", "")], vec![]),
        instance(vec![loc("Z16", "MALO2", "")], vec![]),
        instance(vec![loc("Z16", "MALO3", "")], vec![]),
        instance(vec![loc("Z22", "RUHE1", "T1")], vec![]),
    ];
    let sg6s = vec![
        instance(vec![rff("Z50", "MALO1")], vec![]),
        instance(vec![rff("Z50", "MALO2")], vec![]),
        instance(vec![rff("Z50", "MALO3")], vec![]),
        instance(
            vec![
                seg("RFF", &[&["Z49", "", "T1"]]),
                seg("DTM", &[&["Z25", "202301010000?+00", "303"]]),
            ],
            vec![],
        ),
        instance(
            vec![
                seg("RFF", &[&["Z47", "", "T2"]]),
                seg("DTM", &[&["Z25", "202201010000?+00", "303"]]),
            ],
            vec![],
        ),
    ];
    let pia = |obis: &str| seg("PIA", &[&["5"], &[obis, "SRW"]]);
    let sg8s = vec![
        // 0: MaLo 1 — TLP, Erzeugung, kME/mME, TEP m. Referenzmessung; no ZA9
        sg8(
            "Z01",
            "T1",
            vec![rff("Z18", "MALO1")],
            vec![
                vec![cci("", "ZA6"), cav("E14")],
                vec![cci("Z30", "Z06")],
                vec![cci("", "Z83"), cav("Z53")],
                vec![cci("", "ZA6"), cav("Z36")],
            ],
        ),
        // 1: MaLo 2 — SLP, aggregation by the ÜNB
        sg8(
            "Z01",
            "T2",
            vec![rff("Z18", "MALO2")],
            vec![vec![cci("", "ZA6"), cav("E02")], vec![cci("6", "ZA9")]],
        ),
        // 2, 3: OBIS data of MaLo 1 (kumuliert) and MaLo 2 (1/4 Stunde)
        sg8(
            "Z02",
            "T1",
            vec![rff("Z18", "MALO1"), pia("1-1:1.8.0")],
            vec![],
        ),
        sg8(
            "Z02",
            "T2",
            vec![rff("Z18", "MALO2"), pia("1-1:1.29.0")],
            vec![],
        ),
        // 4, 5: profile data of MaLo 1 and MaLo 2
        sg8("Z21", "T1", vec![rff("Z18", "MALO1")], vec![]),
        sg8("Z21", "T2", vec![rff("Z18", "MALO2")], vec![]),
        // 6, 7: the same Objektcode twice; 8: another one
        sg8(
            "Z58",
            "T1",
            vec![seg("RFF", &[&["Z33", "OBJ1", "1"]])],
            vec![],
        ),
        sg8(
            "Z58",
            "T1",
            vec![seg("RFF", &[&["Z33", "OBJ1", "1"]])],
            vec![],
        ),
        sg8(
            "Z58",
            "T2",
            vec![seg("RFF", &[&["Z33", "OBJ2", "1"]])],
            vec![],
        ),
        // 9, 10: one meter twice; 11: another meter
        sg8("Z20", "T1", vec![rff("MG", "DEV1")], vec![]),
        sg8("Z20", "T1", vec![rff("MG", "DEV1")], vec![]),
        sg8("Z20", "T1", vec![rff("MG", "DEV2")], vec![]),
        // 12, 13: a MaLo in the Pauschal bundle; 14: one outside it
        sg8("Z98", "T1", vec![rff("Z18", "MALO1")], vec![]),
        sg8("Z78", "T1", vec![rff("Z31", "9992 00000 001 8")], vec![]),
        sg8("Z98", "T2", vec![rff("Z18", "MALO2")], vec![]),
    ];
    AssembledTree {
        segments: vec![],
        groups: vec![group(
            "SG4",
            vec![instance(
                vec![seg("IDE", &[&["24"], &["TX1"]])],
                vec![group("SG5", sg5s), group("SG6", sg6s), group("SG8", sg8s)],
            )],
        )],
        post_group_start: 0,
        inter_group_segments: BTreeMap::new(),
    }
}

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

/// `condition` asked from instance `index` of `group` (SG6 or SG8).
fn evaluate(
    evaluator: &dyn ConditionEvaluator,
    group: &str,
    index: usize,
    condition: u32,
) -> ConditionResult {
    let tree = tree();
    let navigator = AssembledTreeNavigator::new(&tree);
    let external = MapExternalProvider::new(Default::default());
    let segments = flat(&tree);
    let ctx = EvaluationContext::with_navigator("55043", &external, &segments, &navigator);
    let path = [("SG4", 0), (group, index)];
    evaluator.evaluate(condition, &ctx.with_scope(GroupScope { path: &path }))
}

/// (condition, group, instance, expected), for FV2504's evaluator.
const FV2504: &[(u32, &str, usize, ConditionResult)] = &[
    // "wie ein RFF+Z18 aus einer SG8 SEQ+Z01, in dem SG10 … vorhanden"
    (117, "SG8", PROFIL_MALO1, True),
    (117, "SG8", PROFIL_MALO2, False),
    (197, "SG8", PROFIL_MALO1, False),
    (197, "SG8", PROFIL_MALO2, True),
    (223, "SG8", PROFIL_MALO1, True),
    (223, "SG8", PROFIL_MALO2, False),
    (86, "SG6", 0, True),
    (86, "SG6", 2, False),
    // a SEQ+Z02 of the same MaLo with Wirkarbeit kumuliert / 1/4 Stunde
    (408, "SG8", PROFIL_MALO1, True),
    (408, "SG8", PROFIL_MALO2, False),
    (409, "SG8", PROFIL_MALO1, False),
    (409, "SG8", PROFIL_MALO2, True),
    (410, "SG8", PROFIL_MALO1, False),
    (410, "SG8", PROFIL_MALO2, True),
    (411, "SG8", PROFIL_MALO1, True),
    (411, "SG8", PROFIL_MALO2, False),
    // the Objektcode in a further SG8 of the same kind
    (175, "SG8", Z58_T1, True),
    (175, "SG8", Z58_T1_SAME, True),
    (175, "SG8", Z58_T2, False),
    (196, "SG8", Z58_T1, True),
    (196, "SG8", Z58_T2, False),
    (229, "SG8", Z58_T1, True),
    (229, "SG8", Z58_T2, False),
    // the SEQ+Z01 with this SG8's Zeitraum-ID
    (266, "SG8", Z58_T1, True),
    (266, "SG8", Z58_T2, False),
    (332, "SG8", Z58_T1, True),
    (332, "SG8", Z58_T2, False),
    // an SG5 LOC+Z22 with this SEQ+Z58's Zeitraum-ID
    (306, "SG8", Z58_T1, True),
    (306, "SG8", Z58_T2, False),
    (307, "SG8", Z58_T1, False),
    (307, "SG8", Z58_T2, True),
    // the SG6 Verwendungszeitraum with this SG8's Zeitraum-ID
    (327, "SG8", Z58_T1, True),
    (327, "SG8", Z58_T2, False),
    // a further SEQ+Z20 with the same Zeitraum-ID and meter
    (124, "SG8", Z20_DEV1, True),
    (124, "SG8", Z20_DEV2, False),
    // this SEQ+Z98's Zeitraum-ID not in a Pauschal SEQ+Z78
    (51, "SG8", Z98_PAUSCHAL, False),
    (51, "SG8", Z98_T2, True),
];

/// (condition, group, instance, expected), for FV2604's own evaluators.
const FV2604: &[(u32, &str, usize, ConditionResult)] = &[
    (160, "SG8", Z58_T1, True),
    (160, "SG8", Z58_T2, False),
    (271, "SG6", 0, True),
    (271, "SG6", 1, False),
];

#[test]
fn each_instance_is_compared_with_the_others_of_its_sg4() {
    let _ = Z01_MALO1;
    let fv2504 = UtilmdStromConditionEvaluatorFV2504::default();
    let fv2604 = UtilmdStromConditionEvaluatorFV2604::default();
    let mut wrong = Vec::new();
    for (evaluator, cases) in [
        (&fv2504 as &dyn ConditionEvaluator, FV2504),
        (&fv2604 as &dyn ConditionEvaluator, FV2604),
    ] {
        for &(condition, group, index, expected) in cases {
            let got = evaluate(evaluator, group, index, condition);
            if got != expected {
                wrong.push(format!(
                    "[{condition}] from {group} {index}: {got:?}, expected {expected:?}"
                ));
            }
        }
    }
    assert!(wrong.is_empty(), "{wrong:#?}");
}

/// [404] "Wenn nicht dieselbe MP-ID in SG2 NAD+MR und im SG8 SEQ+Z01/ Z98 SG10
/// CCI+++ZB3 CAV+Z91 vorhanden": the MSB of the MaLo this SG8 refers to, read
/// from that MaLo's SEQ+Z01 — not from any SG8 (reported by mako-twin: the
/// MeLo's SEQ+Z18 names the recipient by design, so every OBIS item of every
/// MaLo was refused).
#[test]
fn condition_404_reads_the_msb_of_this_sg8s_marktlokation() {
    const RECIPIENT: &str = "9900000000001";
    let msb = |mp_id: &str| {
        vec![
            seg("CCI", &[&[""], &[], &["ZB3"]]),
            seg("CAV", &[&["Z91", mp_id]]),
        ]
    };
    let loc = |id: &str| seg("LOC", &[&["Z16"], &[id]]);
    let tree = AssembledTree {
        segments: vec![seg("NAD", &[&["MR"], &[RECIPIENT, "", "293"]])],
        groups: vec![group(
            "SG4",
            vec![instance(
                vec![seg("IDE", &[&["24"], &["TX1"]])],
                vec![
                    group(
                        "SG5",
                        vec![
                            instance(vec![loc("MALO1")], vec![]),
                            instance(vec![loc("MALO2")], vec![]),
                        ],
                    ),
                    group(
                        "SG8",
                        vec![
                            sg8(
                                "Z01",
                                "",
                                vec![rff("Z18", "MALO1")],
                                vec![msb("9900000000002")],
                            ),
                            sg8("Z01", "", vec![rff("Z18", "MALO2")], vec![msb(RECIPIENT)]),
                            sg8("Z18", "", vec![rff("Z19", "MELO1")], vec![msb(RECIPIENT)]),
                            sg8("Z02", "", vec![rff("Z18", "MALO1")], vec![]),
                            sg8("Z02", "", vec![rff("Z18", "MALO2")], vec![]),
                        ],
                    ),
                ],
            )],
        )],
        post_group_start: 0,
        inter_group_segments: BTreeMap::new(),
    };
    let navigator = AssembledTreeNavigator::new(&tree);
    let external = MapExternalProvider::new(Default::default());
    let mut segments = vec![OwnedSegment {
        id: "NAD".into(),
        elements: vec![
            vec!["MR".into()],
            vec![RECIPIENT.into(), "".into(), "293".into()],
        ],
        segment_number: 0,
    }];
    segments.extend(flat(&tree));
    let ctx = EvaluationContext::with_navigator("55043", &external, &segments, &navigator);
    let evaluator = UtilmdStromConditionEvaluatorFV2504::default();
    let at = |i: usize| {
        let path = [("SG4", 0), ("SG8", i)];
        evaluator.evaluate(404, &ctx.with_scope(GroupScope { path: &path }))
    };
    assert_eq!(at(3), True, "MaLo 1's MSB is not the recipient");
    assert_eq!(at(4), False, "MaLo 2's MSB is the recipient");
}