1mod read;
44mod write;
45mod xml;
46
47use std::f64::consts::PI;
48
49use crate::model::ConductorMatrix;
50
51pub(crate) use read::parse_cim_collecting;
52pub(crate) use write::emit_cim_dist_text;
53
54const DISTRIBUTION_MARKERS: [&[u8]; 12] = [
60 b"ACLineSegmentPhase",
61 b"EnergyConsumerPhase",
62 b"EnergySourcePhase",
63 b"SwitchPhase",
64 b"ShuntCompensatorPhase",
65 b"PerLengthPhaseImpedance",
66 b"PhaseImpedanceData",
67 b"TransformerTank",
68 b"PowerElectronicsConnectionPhase",
69 b"ConcentricNeutralCableInfo",
70 b"ServiceLocation",
71 b"UsagePoint",
72];
73
74pub const CIM_DIST_HEAD_BYTES: usize = 16 * 1024;
76
77#[must_use]
82pub fn looks_like_cim_dist_header(head: &[u8]) -> bool {
83 let head = &head[..head.len().min(CIM_DIST_HEAD_BYTES)];
84 has(head, b"22-rdf-syntax-ns#")
85 && (has(head, b"CIM100#") || has(head, b"CIM-schema-cim"))
86 && !has(head, b"FullModel")
87}
88
89#[must_use]
95pub fn looks_like_cim_dist(bytes: &[u8]) -> bool {
96 looks_like_cim_dist_header(bytes)
97 && DISTRIBUTION_MARKERS.iter().any(|marker| has(bytes, marker))
98}
99
100fn has(haystack: &[u8], needle: &[u8]) -> bool {
101 memchr::memmem::find(haystack, needle).is_some()
102}
103
104fn is_case_document(entry: &str) -> bool {
108 !entry.contains('/')
109 && std::path::Path::new(entry)
110 .extension()
111 .and_then(|extension| extension.to_str())
112 .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
113}
114
115#[must_use]
121pub fn source_looks_like_cim_dist(source: &powerio_core::Source) -> bool {
122 if !source.is_directory() {
123 return source
124 .primary_buffer()
125 .is_ok_and(|buffer| looks_like_cim_dist(buffer.content_bytes()));
126 }
127 let Ok(entries) = source.entry_names() else {
128 return false;
129 };
130 entries.iter().any(|entry| {
131 is_case_document(entry.as_str())
132 && source
133 .root_buffer(entry.as_str())
134 .is_ok_and(|buffer| looks_like_cim_dist(buffer.content_bytes()))
135 })
136}
137
138fn z_base(v_ref: f64, s_rating: f64) -> Option<f64> {
143 (v_ref > 0.0 && s_rating > 0.0).then_some(v_ref * v_ref / s_rating)
144}
145
146fn sequence_to_phase(z0: f64, z1: f64, n: usize) -> ConductorMatrix {
149 let self_z = (z0 + 2.0 * z1) / 3.0;
150 let mutual = (z0 - z1) / 3.0;
151 let mut m = vec![vec![mutual; n]; n];
152 for (i, row) in m.iter_mut().enumerate() {
153 row[i] = self_z;
154 }
155 m
156}
157
158fn wrap_angle(angle: f64) -> f64 {
160 let mut a = angle % (2.0 * PI);
161 if a <= -PI {
162 a += 2.0 * PI;
163 } else if a > PI {
164 a -= 2.0 * PI;
165 }
166 a
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172enum LeakageReading {
173 Magnitude,
174 #[cfg(test)]
175 Reactance,
176}
177
178const LEAKAGE_READING: LeakageReading = LeakageReading::Magnitude;
179
180fn tank_phase_clock(
185 primary: &crate::DistWinding,
186 winding: &crate::DistWinding,
187 three_phase: bool,
188) -> f64 {
189 if three_phase {
190 if primary.conn == winding.conn {
191 0.0
192 } else {
193 1.0
194 }
195 } else if (primary.terminal_map.first().is_some_and(|t| t == "4"))
196 != (winding.terminal_map.first().is_some_and(|t| t == "4"))
197 {
198 6.0
199 } else {
200 0.0
201 }
202}
203
204#[derive(Clone, Copy, Debug, PartialEq)]
206struct ShortCircuitPct {
207 x: f64,
209 r: f64,
211}
212
213fn short_circuit_test_pct(
218 leakage: f64,
219 loss_kw: f64,
220 rated_u: f64,
221 base_power: f64,
222 s_1: f64,
223 reading: LeakageReading,
224) -> Option<ShortCircuitPct> {
225 let base = z_base(rated_u, base_power)?;
226 if s_1 <= 0.0 {
227 return None;
228 }
229 let z = 100.0 * leakage / base;
230 let r = 100.0 * 1000.0 * loss_kw / base_power;
231 let x = match reading {
232 LeakageReading::Magnitude => (z * z - r * r).max(0.0).sqrt(),
233 #[cfg(test)]
234 LeakageReading::Reactance => z,
235 };
236 let scale = s_1 / base_power;
237 Some(ShortCircuitPct {
238 x: x * scale,
239 r: r * scale,
240 })
241}
242
243fn no_load_test_pct(loss_kw: f64, exciting: f64, base_power: f64, s_1: f64) -> Option<(f64, f64)> {
247 if base_power <= 0.0 || s_1 <= 0.0 {
248 return None;
249 }
250 let loss = 100.0 * 1000.0 * loss_kw / s_1;
251 let iexc = exciting * base_power / s_1;
252 let imag = if loss > 0.0 && loss <= iexc {
253 (iexc * iexc - loss * loss).sqrt()
254 } else {
255 iexc
256 };
257 Some((loss, imag))
258}
259
260#[cfg(test)]
261mod tests {
262 use std::f64::consts::PI;
263
264 use super::{
265 LeakageReading, is_case_document, looks_like_cim_dist, no_load_test_pct, sequence_to_phase,
266 short_circuit_test_pct, wrap_angle,
267 };
268
269 fn close(a: f64, b: f64) -> bool {
270 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
271 }
272
273 #[test]
279 fn leakage_impedance_interpretations() {
280 let tests = [
281 (23.5008, 0.09, 2400.0),
282 (23.5008, 0.0, 2400.0),
283 (0.039_168, 0.0, 120.0),
284 ];
285 let read = |reading| -> Vec<f64> {
286 tests
287 .iter()
288 .map(|&(z, loss, u)| {
289 short_circuit_test_pct(z, loss, u, 5000.0, 5000.0, reading)
290 .unwrap()
291 .x
292 })
293 .collect()
294 };
295 let magnitude = read(LeakageReading::Magnitude);
296 assert!(close(
297 magnitude[0],
298 (2.04_f64.powi(2) - 1.8_f64.powi(2)).sqrt()
299 ));
300 assert!(close(magnitude[0], 0.96));
301 assert!(close(magnitude[1], 2.04));
302 assert!(close(magnitude[2], 1.36));
303 let reactance = read(LeakageReading::Reactance);
304 assert!(close(reactance[0], 2.04));
305 assert!(close(reactance[1], 2.04));
306 assert!(close(reactance[2], 1.36));
307 let pair = short_circuit_test_pct(
309 23.5008,
310 0.09,
311 2400.0,
312 5000.0,
313 5000.0,
314 LeakageReading::Reactance,
315 )
316 .unwrap();
317 assert!(close(pair.r, 1.8));
318 }
319
320 #[test]
323 fn short_circuit_tests_rescale_and_flag_short_magnitudes() {
324 let pct = short_circuit_test_pct(
326 23.5008,
327 0.0,
328 2400.0,
329 5000.0,
330 10_000.0,
331 LeakageReading::Magnitude,
332 )
333 .unwrap();
334 assert!(close(pct.x, 4.08));
335 let short =
336 short_circuit_test_pct(1.0, 0.09, 2400.0, 5000.0, 5000.0, LeakageReading::Magnitude)
337 .unwrap();
338 assert!(close(short.x, 0.0));
339 assert!(
340 short_circuit_test_pct(1.0, 0.0, 0.0, 5000.0, 5000.0, LeakageReading::Magnitude)
341 .is_none()
342 );
343 }
344
345 #[test]
348 fn no_load_tests_become_percentages() {
349 let (loss, imag) = no_load_test_pct(0.05, 0.538_516, 25_000.0, 25_000.0).unwrap();
350 assert!(close(loss, 0.2));
351 assert!((imag - 0.5).abs() < 1e-6);
352 let (_, imag) = no_load_test_pct(0.05, 0.1, 25_000.0, 25_000.0).unwrap();
354 assert!(close(imag, 0.1));
355 let (_, imag) = no_load_test_pct(0.0, 0.5, 5000.0, 10_000.0).unwrap();
357 assert!(close(imag, 0.25));
358 }
359
360 #[test]
361 #[allow(clippy::float_cmp)]
362 fn sequence_expands_to_a_symmetric_phase_matrix() {
363 let m = sequence_to_phase(6.0, 3.0, 3);
364 assert_eq!(m[0][0], 4.0);
366 assert_eq!(m[0][1], 1.0);
367 assert_eq!(m[1][0], 1.0);
368 }
369
370 #[test]
371 fn angles_wrap_into_the_half_open_interval() {
372 assert!((wrap_angle(-4.0 * PI / 3.0) - 2.0 * PI / 3.0).abs() < 1e-12);
373 assert!((wrap_angle(PI) - PI).abs() < 1e-12);
374 assert!((wrap_angle(-PI) - PI).abs() < 1e-12);
375 }
376
377 #[test]
378 fn a_case_reads_only_the_top_level_xml_documents() {
379 assert!(is_case_document("feeder.xml"));
380 assert!(is_case_document("feeder.XML"));
381 assert!(!is_case_document("archive/feeder.xml"));
382 assert!(!is_case_document("notes.txt"));
383 }
384
385 const RDF: &str = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cim="http://iec.ch/TC57/CIM100#">"#;
386
387 #[test]
388 fn a_distribution_document_is_recognized_by_its_per_phase_classes() {
389 let doc = format!("{RDF}<cim:ACLineSegmentPhase rdf:ID=\"_p\"/></rdf:RDF>");
390 assert!(looks_like_cim_dist(doc.as_bytes()));
391 }
392
393 #[test]
394 fn a_feeder_with_usage_points_is_distribution_cim() {
395 let cim16 = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">"#;
396 let doc = format!(
397 "{cim16}<cim:ACLineSegment rdf:ID=\"_l\"/><cim:UsagePoint rdf:ID=\"_u\"/></rdf:RDF>"
398 );
399 assert!(looks_like_cim_dist(doc.as_bytes()));
400 }
401
402 #[test]
403 fn a_cgmes_profile_or_a_document_without_markers_is_not() {
404 let header = format!(
405 "{RDF}<md:FullModel rdf:about=\"urn:uuid:1\"/><cim:ACLineSegmentPhase rdf:ID=\"_p\"/></rdf:RDF>"
406 );
407 assert!(!looks_like_cim_dist(header.as_bytes()));
408 let balanced = format!("{RDF}<cim:ACLineSegment rdf:ID=\"_l\"/></rdf:RDF>");
409 assert!(!looks_like_cim_dist(balanced.as_bytes()));
410 assert!(!looks_like_cim_dist(
411 b"<network xmlns=\"http://www.powsybl.org/schema/iidm/1_0\"/>"
412 ));
413 }
414}