Skip to main content

powerio_core/
cim.rs

1//! CIM vocabulary shared by the adapters that speak it: CGMES in
2//! `powerio-tx`, distribution CIM in `powerio-dist`, and MG-RAVENS, whose
3//! balanced profile lives in `powerio-tx`, whose multiconductor profile lives
4//! in `powerio-dist`, and whose two profiles meet in `powerio-tnd`.
5//!
6//! Those crates depend on this one and not on each other, so whatever two of
7//! them must agree on is stated once here: how a stable identifier derives
8//! from an object's class and name, how CIM phase kinds map onto wire
9//! coordinate terminal names, and which classes mark each MG-RAVENS profile.
10
11/// A UUID version 5 identifier for `kind:name` under `namespace`, a URL
12/// naming the adapter that writes it. Same inputs give the same id, so a
13/// canonical write is byte stable and two writes of one case diff only where
14/// the case changed. The CGMES and distribution CIM writers derive their ids
15/// the same way under their own namespaces.
16#[must_use]
17pub fn deterministic_mrid(namespace: &str, kind: &str, name: &str) -> String {
18    let namespace = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, namespace.as_bytes());
19    uuid::Uuid::new_v5(&namespace, format!("{kind}:{name}").as_bytes()).to_string()
20}
21
22/// The wire coordinate terminal name (an OpenDSS node number) of a CIM
23/// `SinglePhaseKind` letter: `A`, `B`, `C`, and `N` are terminals 1 to 4,
24/// and the split phase secondaries `s1` and `s2` are terminals 1 and 2.
25#[must_use]
26pub fn phase_terminal(letter: &str) -> Option<&'static str> {
27    match letter {
28        "A" | "s1" => Some("1"),
29        "B" | "s2" => Some("2"),
30        "C" => Some("3"),
31        "N" => Some("4"),
32        _ => None,
33    }
34}
35
36/// The CIM `SinglePhaseKind` letter of a terminal name: the node numbers 1
37/// to 4, and the letter names `a`, `b`, `c`, `n` in either case that BMOPF
38/// terminal conventions use. Split phase collapses onto `A` and `B`. Any
39/// other name has no phase kind, and a writer reports it.
40#[must_use]
41pub fn terminal_phase(terminal: &str) -> Option<&'static str> {
42    match terminal {
43        "1" | "a" | "A" => Some("A"),
44        "2" | "b" | "B" => Some("B"),
45        "3" | "c" | "C" => Some("C"),
46        "4" | "n" | "N" => Some("N"),
47        _ => None,
48    }
49}
50
51/// Expand a `PhaseCode.<code>` or `OrderedPhaseCodeKind.<code>` value into
52/// its letters: `ABCN` gives `A`, `B`, `C`, `N`, and `s12N` (or the
53/// `s1s2N` spelling) gives `s1`, `s2`, `N`. Letters outside the kind are skipped.
54#[must_use]
55pub fn expand_phase_code(code: &str) -> Vec<String> {
56    let code = code.rsplit('.').next().unwrap_or(code);
57    let mut out = Vec::new();
58    let mut chars = code.chars().peekable();
59    while let Some(c) = chars.next() {
60        match c {
61            'A' | 'B' | 'C' | 'N' => out.push(c.to_string()),
62            's' => {
63                if let Some(d) = chars.next() {
64                    out.push(format!("s{d}"));
65                    if matches!(d, '1' | '2')
66                        && chars
67                            .peek()
68                            .is_some_and(|next| matches!(next, '1' | '2') && *next != d)
69                        && let Some(next) = chars.next()
70                    {
71                        out.push(format!("s{next}"));
72                    }
73                }
74            }
75            _ => {}
76        }
77    }
78    out
79}
80
81/// MG-RAVENS, the LANL CIM-derived microgrid interchange schema: one JSON
82/// document of name-keyed hash tables nested under the CIM class hierarchy.
83/// Its two profiles share the token and the top level shape and differ in
84/// the classes they use, so the profile follows the content.
85pub mod ravens {
86    /// The format token both profiles share.
87    pub const TOKEN: &str = "ravens-json";
88
89    /// The namespace both profiles derive deterministic mRIDs under.
90    pub const NAMESPACE: &str = "https://powerio.dev/ravens";
91
92    /// Classes only the balanced profile uses: the MATPOWER converter's
93    /// settings record, polynomial costs, and two-end transformers.
94    pub const BALANCED_MARKERS: [&str; 3] = [
95        "AlgorithmSettings",
96        "ProducerCostFunction",
97        "PowerTransformerEnd",
98    ];
99
100    /// Classes only the multiconductor profile uses: per-phase equipment,
101    /// transformer tanks, per-length impedance matrices, and wire catalogs.
102    pub const MULTICONDUCTOR_MARKERS: [&str; 20] = [
103        "ACLineSegmentPhase",
104        "EnergyConsumerPhase",
105        "PowerElectronicsConnectionPhase",
106        "SwitchPhase",
107        "TransformerTank",
108        "TransformerTankEnd",
109        "PerLengthPhaseImpedance",
110        "PerLengthLineParameter",
111        "WireSpacingInfo",
112        "Switch",
113        "ProtectedSwitch",
114        "LoadBreakSwitch",
115        "Breaker",
116        "Recloser",
117        "Cut",
118        "Fuse",
119        "Disconnector",
120        "GroundDisconnector",
121        "Sectionaliser",
122        "Jumper",
123    ];
124
125    /// The profile a document's classes select.
126    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
127    #[non_exhaustive]
128    pub enum Profile {
129        /// The MATPOWER derived register, read into a balanced network. A
130        /// document with neither profile's markers reads as this one.
131        Balanced,
132        /// The OpenDSS derived register, read into a multiconductor network.
133        Multiconductor,
134        /// Both registers at once, which no reader takes.
135        Mixed,
136    }
137
138    impl Profile {
139        /// The profile's name in messages.
140        #[must_use]
141        pub fn name(self) -> &'static str {
142            match self {
143                Self::Balanced => "balanced",
144                Self::Multiconductor => "multiconductor",
145                Self::Mixed => "mixed",
146            }
147        }
148    }
149
150    /// The profile of MG-RAVENS JSON text, from decoded
151    /// `Ravens.cimObjectType` values and the schema's class containers.
152    /// Object names and ordinary string properties do not select a profile.
153    /// Malformed or excessively nested JSON defaults to the balanced route,
154    /// whose reader reports the JSON error; the decoder keeps its depth limit.
155    #[must_use]
156    pub fn detect_profile(text: &str) -> Profile {
157        use serde_json::Value;
158
159        let Ok(root) = serde_json::from_str::<Value>(text) else {
160            return Profile::Balanced;
161        };
162        // These are structural containers in the MG-RAVENS schema, not
163        // arbitrary hash keys: ConnectivityNode["TransformerTank"], for
164        // example, names a bus and is not a transformer class statement.
165        let mut balanced = root
166            .get("ProducerCostFunction")
167            .is_some_and(Value::is_object);
168        let mut multiconductor = root
169            .get("PerLengthLineParameter")
170            .is_some_and(Value::is_object)
171            || root
172                .get("AssetInfo")
173                .and_then(|info| info.get("WireSpacingInfo"))
174                .is_some_and(Value::is_object);
175        visit_classes(&root, &mut balanced, &mut multiconductor);
176        match (balanced, multiconductor) {
177            (true, true) => Profile::Mixed,
178            (false, true) => Profile::Multiconductor,
179            _ => Profile::Balanced,
180        }
181    }
182
183    fn visit_classes(value: &serde_json::Value, balanced: &mut bool, multiconductor: &mut bool) {
184        use serde_json::Value;
185
186        match value {
187            Value::Object(object) => {
188                if let Some(kind) = object.get("Ravens.cimObjectType").and_then(Value::as_str) {
189                    *balanced |= BALANCED_MARKERS.contains(&kind);
190                    *multiconductor |= MULTICONDUCTOR_MARKERS.contains(&kind);
191                }
192                for child in object.values() {
193                    visit_classes(child, balanced, multiconductor);
194                }
195            }
196            Value::Array(items) => {
197                for child in items {
198                    visit_classes(child, balanced, multiconductor);
199                }
200            }
201            _ => {}
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn deterministic_mrids_are_stable_version_5_uuids() {
212        let a = deterministic_mrid(ravens::NAMESPACE, "ConnectivityNode", "1");
213        assert_eq!(
214            a,
215            deterministic_mrid(ravens::NAMESPACE, "ConnectivityNode", "1")
216        );
217        assert_ne!(
218            a,
219            deterministic_mrid(ravens::NAMESPACE, "ConnectivityNode", "2")
220        );
221        assert_ne!(
222            a,
223            deterministic_mrid("https://powerio.dev/cim-dist", "ConnectivityNode", "1")
224        );
225        assert_eq!(a.len(), 36);
226        assert_eq!(a.as_bytes()[14], b'5', "version nibble");
227    }
228
229    #[test]
230    fn phase_kinds_map_both_ways() {
231        for (letter, terminal) in [("A", "1"), ("B", "2"), ("C", "3"), ("N", "4")] {
232            assert_eq!(phase_terminal(letter), Some(terminal));
233            assert_eq!(terminal_phase(terminal), Some(letter));
234            assert_eq!(terminal_phase(&letter.to_ascii_lowercase()), Some(letter));
235        }
236        assert_eq!(phase_terminal("s1"), Some("1"));
237        assert_eq!(phase_terminal("s2"), Some("2"));
238        assert_eq!(terminal_phase("5"), None);
239        assert_eq!(terminal_phase("x"), None);
240        assert_eq!(expand_phase_code("PhaseCode.ABCN"), ["A", "B", "C", "N"]);
241        assert_eq!(expand_phase_code("OrderedPhaseCodeKind.CA"), ["C", "A"]);
242        assert_eq!(expand_phase_code("PhaseCode.s1s2N"), ["s1", "s2", "N"]);
243        assert_eq!(expand_phase_code("PhaseCode.s12"), ["s1", "s2"]);
244        assert_eq!(expand_phase_code("PhaseCode.s12N"), ["s1", "s2", "N"]);
245        assert_eq!(expand_phase_code("A"), ["A"]);
246    }
247
248    #[test]
249    fn ravens_profiles_follow_the_classes_present() {
250        use ravens::{Profile, detect_profile};
251        assert_eq!(
252            detect_profile(r#"{"ConnectivityNode":{}}"#),
253            Profile::Balanced
254        );
255        assert_eq!(
256            detect_profile(r#"{"x":{"Ravens.cimObjectType":"ProducerCostFunction"}}"#),
257            Profile::Balanced
258        );
259        assert_eq!(
260            detect_profile(r#"{"PerLengthLineParameter":{}}"#),
261            Profile::Multiconductor
262        );
263        assert_eq!(
264            detect_profile(r#"[{"Ravens.cimObjectType":"ACLineSegmentPhase"}]"#),
265            Profile::Multiconductor
266        );
267        assert_eq!(
268            detect_profile(
269                r#"{"PerLengthLineParameter":{},"y":{"Ravens.cimObjectType":"AlgorithmSettings"}}"#
270            ),
271            Profile::Mixed
272        );
273        // A pointer or a property key is not a class statement.
274        assert_eq!(
275            detect_profile(r#"{"a":"PerLengthPhaseImpedance::'lc'","PowerTransformerEnd.r":1}"#),
276            Profile::Balanced
277        );
278    }
279
280    #[test]
281    fn ravens_profiles_ignore_names_and_descriptions() {
282        use ravens::{Profile, detect_profile};
283        for marker in ravens::MULTICONDUCTOR_MARKERS {
284            let text = serde_json::json!({
285                "ConnectivityNode": {marker: {
286                    "Ravens.cimObjectType": "ConnectivityNode",
287                    "IdentifiedObject.name": marker,
288                    "IdentifiedObject.description": marker
289                }},
290                "MySettings": {"Ravens.cimObjectType": "AlgorithmSettings"}
291            });
292            assert_eq!(
293                detect_profile(&text.to_string()),
294                Profile::Balanced,
295                "{marker}"
296            );
297        }
298        for marker in ravens::BALANCED_MARKERS {
299            let text = serde_json::json!({
300                "ConnectivityNode": {marker: {"IdentifiedObject.description": marker}},
301                "PerLengthLineParameter": {}
302            });
303            assert_eq!(
304                detect_profile(&text.to_string()),
305                Profile::Multiconductor,
306                "{marker}"
307            );
308        }
309    }
310
311    #[test]
312    fn ravens_profiles_decode_escapes_and_structural_containers() {
313        use ravens::{Profile, detect_profile};
314        for text in [
315            r#"{"PerLengthLine\u0050arameter":{}}"#,
316            r#"{"AssetInfo":{"WireSpacing\u0049nfo":{}}}"#,
317            r#"{"x":{"Ravens.cimObject\u0054ype":"ACLineSegment\u0050hase"}}"#,
318        ] {
319            assert_eq!(detect_profile(text), Profile::Multiconductor, "{text}");
320        }
321        assert_eq!(detect_profile(r#"{"AssetInfo":{}}"#), Profile::Balanced);
322        assert_eq!(
323            detect_profile(r#"{"ProducerCost\u0046unction":{},"PerLengthLineParameter":{}}"#),
324            Profile::Mixed
325        );
326    }
327
328    #[test]
329    fn malformed_ravens_profile_input_is_bounded() {
330        use ravens::{Profile, detect_profile};
331        for text in [
332            "",
333            "{",
334            r#"{"Ravens.cimObjectType":"TransformerTank"} trailing"#,
335        ] {
336            assert_eq!(detect_profile(text), Profile::Balanced);
337        }
338        let text = format!("{}null{}", "[".repeat(2048), "]".repeat(2048));
339        assert_eq!(detect_profile(&text), Profile::Balanced);
340    }
341}