cimoxide-validation 0.6.0

SHACL validation of CGMES datasets against the ENTSO-E profile constraints
Documentation
use cimmodel::CimDataset;
use super::Fields;
use crate::Violation;

const PROF_EQ:   &str = "http://iec.ch/TC57/ns/CIM/CoreEquipment-EU/3.0";
const PROF_EQBD: &str = "http://iec.ch/TC57/ns/CIM/EquipmentBoundary-EU/3.0";
const PROF_DY:   &str = "http://iec.ch/TC57/ns/CIM/Dynamics-EU/1.0";
const PROF_DL:   &str = "http://iec.ch/TC57/ns/CIM/DiagramLayout-EU/3.0";
const PROF_SC:   &str = "http://iec.ch/TC57/ns/CIM/ShortCircuit-EU/3.0";
const PROF_OP:   &str = "http://iec.ch/TC57/ns/CIM/Operation-EU/3.0";
const PROF_GL:   &str = "http://iec.ch/TC57/ns/CIM/GeographicalLocation-EU/3.0";
const PROF_SV:   &str = "http://iec.ch/TC57/ns/CIM/StateVariables-EU/3.0";
const PROF_TP:   &str = "http://iec.ch/TC57/ns/CIM/Topology-EU/3.0";
const PROF_SSH:  &str = "http://iec.ch/TC57/ns/CIM/SteadyStateHypothesis-EU/3.0";

pub fn validate(dataset: &CimDataset) -> Vec<Violation> {
    let mut v = Vec::new();
    for mrid in dataset.by_type.get("FullModel").into_iter().flatten() {
        let entry = &dataset.entries[mrid];
        if let Some(fm) = Fields::of_class(entry, "FullModel") {
            v.extend(check_prof10_model(mrid, &fm, dataset));
        }
    }
    v
}

fn profile_uri<'a>(profiles: &[&'a str]) -> &'a str {
    for p in profiles {
        let p = p.trim();
        if !p.is_empty() { return p; }
    }
    ""
}

fn dependent_on_profiles(dependent_on: &[String], dataset: &CimDataset) -> Vec<String> {
    if dependent_on.is_empty() { return Vec::new(); }
    let mut out = Vec::new();
    for dep_ref in dependent_on {
        let dep_id = dep_ref.trim_start_matches('#');
        if let Some(entry) = dataset.entries.get(dep_id) {
            let prof = match Fields::of_class(entry, "FullModel").or_else(|| Fields::of_class(entry, "DifferenceModel")) {
                Some(m) => profile_uri(&m.texts("Model.profile")).to_string(),
                None => "external".to_string(),
            };
            out.push(prof);
        } else {
            out.push("external".to_string());
        }
    }
    out
}

fn has_value(deps: &[String], target: &str) -> bool {
    deps.iter().any(|d| d == target)
}

fn all_in_set(deps: &[String], allowed: &[&str]) -> bool {
    deps.iter().all(|d| d == "external" || allowed.contains(&d.as_str()))
}

fn dataset_has_profile(dataset: &CimDataset, prof: &str) -> bool {
    for type_name in &["FullModel", "DifferenceModel"] {
        for mrid in dataset.by_type.get(*type_name).into_iter().flatten() {
            let entry = &dataset.entries[mrid];
            let p = match Fields::of_class(entry, type_name) {
                Some(m) => profile_uri(&m.texts("Model.profile")).to_string(),
                None => continue,
            };
            if p == prof { return true; }
        }
    }
    false
}

fn prof10_violation(id: &str, rule_id: &str, msg: &str, severity: &str) -> Violation {
    Violation {
        object_id:   id.to_string(),
        rule_id:     rule_id.to_string(),
        name:        "C:600:ALL:NA:PROF10".into(),
        class:       "FullModel".into(),
        property:    "Model.DependentOn".into(),
        message:     msg.to_string(),
        severity:    severity.to_string(),
        description: "CGMES instance file (distribution) dependency shall be declared by md:Model.DependentOn in the header according to Figure 1 and the associated rules.".into(),
    }
}

fn check_prof10_model(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    match profile_uri(&m.texts("Model.profile")) {
        PROF_EQ  => check_prof10_eq(id, m, dataset),
        PROF_DY  => check_prof10_dy(id, m, dataset),
        PROF_DL  => check_prof10_dl(id, m, dataset),
        PROF_SC  => check_prof10_sc(id, m, dataset),
        PROF_OP  => check_prof10_op(id, m, dataset),
        PROF_GL  => check_prof10_gl(id, m, dataset),
        PROF_SV  => check_prof10_sv(id, m, dataset),
        PROF_TP  => check_prof10_tp(id, m, dataset),
        PROF_SSH => check_prof10_ssh(id, m, dataset),
        _        => Vec::new(),
    }
}

const MSG_EQ: &str = "The EQ does not have reference to EQBD. The file header dependencies cardinalities and types for EQ profile are not according to PROF10.";
fn check_prof10_eq(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if has_value(&deps, PROF_EQBD) || has_value(&deps, "external") { return Vec::new(); }
    vec![prof10_violation(id, "prof10:PROF10-EQ", MSG_EQ, "sh:Info")]
}

const MSG_DY: &str = "The file header dependencies cardinalities and types for DY profile are not according to PROF10.";
fn check_prof10_dy(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-DY", MSG_DY, "sh:Violation")]; }
    if has_value(&deps, PROF_EQ) { return Vec::new(); }
    if has_value(&deps, "external") && !dataset_has_profile(dataset, PROF_EQ) { return Vec::new(); }
    vec![prof10_violation(id, "prof10:PROF10-DY", MSG_DY, "sh:Violation")]
}

const MSG_DL: &str = "The file header dependencies cardinalities and types for DL profile are not according to PROF10.";
fn check_prof10_dl(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if !all_in_set(&deps, &[PROF_DY, PROF_TP, PROF_EQ, PROF_SC, PROF_OP]) {
        return vec![prof10_violation(id, "prof10:PROF10-DL", MSG_DL, "sh:Violation")];
    }
    Vec::new()
}

const MSG_SC: &str = "The file header dependencies cardinalities and types for SC profile are not according to PROF10.";
fn check_prof10_sc(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-SC", MSG_SC, "sh:Violation")]; }
    if !all_in_set(&deps, &[PROF_EQ, PROF_EQBD, PROF_OP]) {
        return vec![prof10_violation(id, "prof10:PROF10-SC", MSG_SC, "sh:Violation")];
    }
    Vec::new()
}

const MSG_OP: &str = "The file header dependencies cardinalities and types for OP profile are not according to PROF10.";
fn check_prof10_op(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-OP", MSG_OP, "sh:Violation")]; }
    if !all_in_set(&deps, &[PROF_EQ, PROF_EQBD, PROF_SC]) {
        return vec![prof10_violation(id, "prof10:PROF10-OP", MSG_OP, "sh:Violation")];
    }
    Vec::new()
}

const MSG_GL: &str = "The file header dependencies cardinalities and types for GL profile are not according to PROF10.";
fn check_prof10_gl(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if !all_in_set(&deps, &[PROF_EQBD, PROF_EQ, PROF_SC, PROF_OP]) {
        return vec![prof10_violation(id, "prof10:PROF10-GL", MSG_GL, "sh:Violation")];
    }
    Vec::new()
}

const MSG_SV: &str = "The file header dependencies cardinalities and types for SV profile are not according to PROF10.";
fn check_prof10_sv(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-SV", MSG_SV, "sh:Violation")]; }
    if has_value(&deps, PROF_TP) { return Vec::new(); }
    if has_value(&deps, "external") && !dataset_has_profile(dataset, PROF_TP) { return Vec::new(); }
    vec![prof10_violation(id, "prof10:PROF10-SV", MSG_SV, "sh:Violation")]
}

const MSG_TP: &str = "The file header dependencies cardinalities and types for TP profile are not according to PROF10.";
fn check_prof10_tp(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-TP", MSG_TP, "sh:Violation")]; }
    if has_value(&deps, PROF_SSH) { return Vec::new(); }
    if has_value(&deps, "external") && !dataset_has_profile(dataset, PROF_SSH) { return Vec::new(); }
    vec![prof10_violation(id, "prof10:PROF10-TP", MSG_TP, "sh:Violation")]
}

const MSG_SSH: &str = "The file header dependencies cardinalities and types for SSH profile are not according to PROF10.";
fn check_prof10_ssh(id: &str, m: &Fields, dataset: &CimDataset) -> Vec<Violation> {
    let deps = dependent_on_profiles(m.references("Model.DependentOn"), dataset);
    if deps.is_empty() { return vec![prof10_violation(id, "prof10:PROF10-SSH", MSG_SSH, "sh:Violation")]; }
    if has_value(&deps, PROF_EQ) { return Vec::new(); }
    if has_value(&deps, "external") && !dataset_has_profile(dataset, PROF_EQ) { return Vec::new(); }
    vec![prof10_violation(id, "prof10:PROF10-SSH", MSG_SSH, "sh:Violation")]
}