Skip to main content

cimvalidation/
lib.rs

1pub mod violation;
2pub use violation::Violation;
3
4pub mod sparql;
5pub mod detect;
6pub use detect::{detect_config, detect_nc_profiles};
7
8use std::collections::HashSet;
9
10#[derive(Debug, Default, Clone)]
11pub struct Config {
12    /// Profile short names to validate (e.g. "EQ", "SSH"). Empty = all detected.
13    pub profiles: Vec<String>,
14    /// True if SV profile (power-flow results) is present.
15    pub solved: bool,
16    /// True if SV profile is absent.
17    pub not_solved: bool,
18    /// Run common cross-profile checks.
19    pub common: bool,
20    /// Run CIMdesk modeling quality checks.
21    pub quality: bool,
22    /// Rule IDs to suppress in the output.
23    pub silenced_rules: Vec<String>,
24    /// When non-empty, enables the EQBD2 base voltage check.
25    pub eqbd_base_voltage_ids: Option<HashSet<String>>,
26}
27
28pub mod helpers;
29mod par;
30
31// ── property-bag families ──────────────────────────────────────────────────
32//
33// Elements of both families are property bags; each family's shapes are a
34// data table interpreted by `bag`.
35pub mod bag;
36pub mod shapes;
37pub mod shape_source;
38pub mod nc_shapes;
39pub mod nc_profiles;
40
41/// The NC shape table in force: loaded from SHACL if the `dynamic-shapes`
42/// feature is on and a directory was supplied, otherwise the generated one.
43pub fn nc_shapes() -> &'static [shapes::ShapeDef] {
44    static R: std::sync::OnceLock<&'static [shapes::ShapeDef]> = std::sync::OnceLock::new();
45    R.get_or_init(|| shape_source::resolve("nc", nc_shapes::SHAPES))
46}
47
48/// Profile IRI → short code, and every short code.
49pub type ProfileIndex = (&'static [(&'static str, &'static str)], &'static [&'static str]);
50
51/// The NC profile index in force. Loaded together with the shapes, since an
52/// index and a table from different releases would run the wrong rules.
53pub fn nc_profile_index() -> ProfileIndex {
54    static R: std::sync::OnceLock<ProfileIndex> = std::sync::OnceLock::new();
55    *R.get_or_init(|| {
56        shape_source::resolve_profiles("nc", nc_profiles::PROFILE_IRIS, nc_profiles::PROFILES)
57    })
58}
59
60/// Run one NC profile's shapes against a dataset, and the hand-written rules
61/// of the files every NC profile imports (`sparql::nc::validate_local`).
62///
63/// Separate from `validate_profile_local` so a caller can drive it with an
64/// explicit profile code, without depending on header detection.
65pub fn validate_nc_profile(
66    dataset: &cimmodel::CimDataset,
67    profile: &str,
68    cfg: &Config,
69) -> Vec<Violation> {
70    let mut v = bag::validate_profile(dataset, profile, nc_shapes(), cfg);
71    // Every NC profile's manifest imports the files these come from, and no
72    // other code names one.
73    if nc_profile_index().1.contains(&profile) {
74        v.extend(sparql::nc::validate_local(dataset));
75    }
76    v
77}
78
79/// The NC rules that relate datasets: the Complex files only the combined
80/// manifest imports, as table shapes and hand-written rules, on the merged
81/// dataset. [`validate_crossprofile`] runs this when an NC profile is in play.
82pub fn validate_nc_merged(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
83    let mut v = bag::validate_profile(dataset, MERGED_PROFILE, nc_shapes(), cfg);
84    v.extend(sparql::nc::validate_merged(dataset));
85    v
86}
87
88/// The profile code of the shapes [`validate_nc_merged`] runs: the combined
89/// manifest's keyword, as `cimschema::shacl::resolve::MERGED_PROFILE` assigns it.
90/// Repeated here because `cimoxide-schema` is optional; a test holds the two
91/// together.
92pub const MERGED_PROFILE: &str = "ALL";
93
94
95// ── CGMES shape table ──────────────────────────────────────────────────────
96//
97// CGMES runs through the same interpreter as NC, reading the typed elements'
98// decoder blocks; it replaced ~250k lines of generated per-check functions.
99// Its constraint files carry tags rather than profile codes — see
100// `cimschema::shacl::cgmes_manifest` — so one table holds the local,
101// not-solved, cross-profile and header rules apart.
102pub mod cgmes_shapes;
103pub mod cgmes_profiles;
104
105/// The CGMES shape table in force: loaded from SHACL if the `dynamic-shapes`
106/// feature is on and a directory was supplied, otherwise the generated one.
107pub fn cgmes_shapes() -> &'static [shapes::ShapeDef] {
108    static R: std::sync::OnceLock<&'static [shapes::ShapeDef]> = std::sync::OnceLock::new();
109    R.get_or_init(|| shape_source::resolve("cgmes", cgmes_shapes::SHAPES))
110}
111
112/// The CGMES shapes carrying one manifest tag, indexed once per process.
113fn cgmes_tagged(tag: &str) -> &'static [&'static shapes::ShapeDef] {
114    type Index = std::collections::HashMap<&'static str, Vec<&'static shapes::ShapeDef>>;
115    static IDX: std::sync::OnceLock<Index> = std::sync::OnceLock::new();
116    let idx = IDX.get_or_init(|| {
117        let mut idx = Index::new();
118        for shape in cgmes_shapes() {
119            for tag in shape.profiles {
120                idx.entry(*tag).or_default().push(shape);
121            }
122        }
123        idx
124    });
125    idx.get(tag).map_or(&[], Vec::as_slice)
126}
127
128fn run_cgmes(dataset: &cimmodel::CimDataset, active: &[&shapes::ShapeDef]) -> Vec<Violation> {
129    bag::validate_shapes(dataset, bag::Source::Cgmes, active)
130}
131
132// ── public two-phase API ───────────────────────────────────────────────────
133
134/// Phase 1 — header: validate FullModel/DifferenceModel rules for a single file's dataset.
135pub fn validate_header(dataset: &cimmodel::CimDataset, _cfg: &Config) -> Vec<Violation> {
136    run_cgmes(dataset, cgmes_tagged("HDR"))
137}
138
139/// The SHACL half of [`validate_profile_local`] for one CGMES profile: its
140/// local rules. Without the hand-written `sparql` rules or the `cfg.profiles`
141/// filter.
142pub fn validate_profile_shacl(dataset: &cimmodel::CimDataset, profile: &str, _cfg: &Config) -> Vec<Violation> {
143    run_cgmes(dataset, cgmes_tagged(profile))
144}
145
146/// The SHACL half of [`validate_crossprofile`]: every enabled profile's
147/// cross-profile rules on the merged dataset, its not-solved (`!NS`, the
148/// NotSolvedMAS files) rules when `cfg.not_solved` — written for a model
149/// authority set, like the hand-written ones in [`sparql::mas_groups`] — and
150/// the rules for every profile when `cfg.common`.
151pub fn validate_crossprofile_shacl(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
152    let has = |p: &str| cfg.profiles.is_empty() || cfg.profiles.iter().any(|x| x == p);
153    let mut active: Vec<&shapes::ShapeDef> = cimschema_cross_profiles()
154        .iter()
155        .filter(|p| has(p))
156        .flat_map(|p| cgmes_tagged(&format!("X:{p}")).iter().copied())
157        .collect();
158    if cfg.not_solved {
159        for p in cgmes_profile_index().1.iter().filter(|p| has(p)) {
160            active.extend_from_slice(cgmes_tagged(&format!("{p}!NS")));
161        }
162    }
163    if cfg.common {
164        active.extend_from_slice(cgmes_tagged("COMMON"));
165    }
166    run_cgmes(dataset, &active)
167}
168
169/// The CGMES profile index in force: `md:Model.profile` IRI → short code, and
170/// every short code, read from `CGMES/PROF`. Loaded together with the shapes,
171/// like [`nc_profile_index`].
172pub fn cgmes_profile_index() -> ProfileIndex {
173    static R: std::sync::OnceLock<ProfileIndex> = std::sync::OnceLock::new();
174    *R.get_or_init(|| {
175        shape_source::resolve_profiles("cgmes", cgmes_profiles::PROFILE_IRIS, cgmes_profiles::PROFILES)
176    })
177}
178
179/// The profiles with cross-profile rules. Mirrors
180/// `cimschema::shacl::cgmes_manifest::CROSS_PROFILES`, which this crate does
181/// not depend on outside the `dynamic-shapes` feature.
182fn cimschema_cross_profiles() -> &'static [&'static str] {
183    &["DL", "DY", "GL", "OP", "SV", "TP"]
184}
185
186
187/// Phase 1 — per-profile: run the local (non-crossprofile) SHACL rules and the SPARQL
188/// rules for one profile.
189///
190/// Pass the single-file dataset for the profile and the combined config (solved/not_solved
191/// must reflect the full set of files, not just this file). If `cfg.profiles` is non-empty
192/// and does not include `profile`, returns an empty vec — this filter applies to both the
193/// SHACL and SPARQL checks.
194pub fn validate_profile_local(dataset: &cimmodel::CimDataset, profile: &str, cfg: &Config) -> Vec<Violation> {
195    if !cfg.profiles.is_empty() && !cfg.profiles.iter().any(|p| p == profile) {
196        return Vec::new();
197    }
198    // NC profile codes cannot collide with CGMES ones, so one flat
199    // `Config::profiles` list carries both families. The CGMES codes are
200    // checked first: asking the NC index loads the NC table — 170 ms when it
201    // comes from SHACL — for data that has no NC in it.
202    if !cgmes_profile_index().1.contains(&profile) && nc_profile_index().1.contains(&profile) {
203        return validate_nc_profile(dataset, profile, cfg);
204    }
205
206    let mut v = validate_profile_shacl(dataset, profile, cfg);
207    v.extend(sparql::validate_profile_local(dataset, profile, cfg));
208    v
209}
210
211
212/// Phase 2 — crossprofile: run crossprofile SHACL + cross-profile SPARQL on the merged dataset.
213///
214/// The two halves run concurrently; the SPARQL half spreads its rule groups
215/// over threads of its own.
216pub fn validate_crossprofile(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
217    let (mut v, sparql) = std::thread::scope(|s| {
218        let sparql = s.spawn(|| sparql::validate_crossprofile(dataset, cfg));
219        let shacl = validate_crossprofile_shacl(dataset, cfg);
220        (shacl, sparql.join().expect("validation thread panicked"))
221    });
222    v.extend(sparql);
223    // CGMES codes first, as in `validate_profile_local`: data without NC
224    // never loads the NC table.
225    let cgmes = cgmes_profile_index().1;
226    if cfg.profiles.iter().any(|p| !cgmes.contains(&p.as_str()) && nc_profile_index().1.contains(&p.as_str())) {
227        v.extend(validate_nc_merged(dataset, cfg));
228    }
229    v
230}
231
232/// Build a combined `Config` by auto-detecting profiles/solved-state across all files,
233/// then applying explicit overrides (each `None`/default leaves the detected value).
234pub fn combined_config(
235    per_file: &[cimmodel::CimDataset],
236    profiles: Option<Vec<String>>,
237    solved: Option<bool>,
238    common: bool,
239    quality: bool,
240    silenced_rules: Vec<String>,
241) -> Config {
242    let mut cfg = Config::default();
243    for ds in per_file {
244        let c = detect_config(ds);
245        for p in c.profiles.into_iter().chain(detect_nc_profiles(ds)) {
246            if !cfg.profiles.contains(&p) {
247                cfg.profiles.push(p);
248            }
249        }
250        cfg.solved |= c.solved;
251    }
252    cfg.not_solved = !cfg.solved;
253    if let Some(p) = profiles { cfg.profiles = p; }
254    if let Some(s) = solved { cfg.solved = s; cfg.not_solved = !s; }
255    cfg.common = common;
256    cfg.quality = quality;
257    cfg.silenced_rules = silenced_rules;
258    cfg
259}
260
261/// Run full two-phase validation: per-file local checks (header + per-profile SHACL/SPARQL)
262/// in parallel, then crossprofile checks on the merged dataset, then apply rule silencing.
263///
264/// Consumes `per_file` since it merges them into one dataset for phase 2.
265pub fn validate_files(per_file: Vec<cimmodel::CimDataset>, cfg: &Config) -> Vec<Violation> {
266    let mut violations: Vec<Violation> = std::thread::scope(|s| {
267        per_file
268            .iter()
269            .map(|ds| {
270                s.spawn(move || {
271                    let mut v = validate_header(ds, cfg);
272                    let mut profiles = detect_config(ds).profiles;
273                    profiles.extend(detect_nc_profiles(ds));
274                    for profile in &profiles {
275                        v.extend(validate_profile_local(ds, profile, cfg));
276                    }
277                    v
278                })
279            })
280            .collect::<Vec<_>>()
281            .into_iter()
282            .flat_map(|h| h.join().expect("validation thread panicked"))
283            .collect()
284    });
285
286    let mut merged = cimmodel::CimDataset::new();
287    for ds in per_file {
288        merged.merge(ds);
289    }
290    violations.extend(validate_crossprofile(&merged, cfg));
291
292    if !cfg.silenced_rules.is_empty() {
293        let silenced: HashSet<&str> = cfg.silenced_rules.iter().map(String::as_str).collect();
294        violations.retain(|v| !silenced.contains(v.rule_id.as_str()));
295    }
296    violations
297}