1#[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#[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#[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#[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
81pub mod ravens {
86 pub const TOKEN: &str = "ravens-json";
88
89 pub const NAMESPACE: &str = "https://powerio.dev/ravens";
91
92 pub const BALANCED_MARKERS: [&str; 3] = [
95 "AlgorithmSettings",
96 "ProducerCostFunction",
97 "PowerTransformerEnd",
98 ];
99
100 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 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
127 #[non_exhaustive]
128 pub enum Profile {
129 Balanced,
132 Multiconductor,
134 Mixed,
136 }
137
138 impl Profile {
139 #[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 #[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 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 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}