1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::model::{DistLineCode, MulticonductorNetwork};
13
14pub(crate) const DEFERRED_GEOMETRY_KEYS: [&str; 5] =
15 ["geometry", "spacing", "wires", "cncables", "tscables"];
16
17pub(crate) const UNRESOLVED_IMPEDANCE_KEY: &str = "cim_impedance_unresolved";
20
21const CONVERSION_BLOCKERS: [&str; 2] = [
24 "READINESS.DSS.GEOMETRY_DEFERRED",
25 "READINESS.CIM_DIST.IMPEDANCE_UNRESOLVED",
26];
27
28pub fn require_electrical_readiness(
30 net: &MulticonductorNetwork,
31) -> Result<(), powerio_core::Error> {
32 let report = audit_electrical_readiness(net);
33 if let Some(finding) = report.blockers().next() {
34 return Err(powerio_core::Error::new(
35 &crate::diagnostics::codes::BUILD_DIST_ELECTRICAL_INCOMPLETE,
36 format!("{} {}: {}", finding.code, finding.element, finding.message),
37 ));
38 }
39 Ok(())
40}
41
42pub fn require_earth_referenced_sources(
45 net: &MulticonductorNetwork,
46) -> Result<(), powerio_core::Error> {
47 if let Some(source) = net.sources().iter().find(|s| {
48 s.reference_terminal.as_ref().is_some_and(|reference| {
49 !net.buses()
50 .iter()
51 .any(|bus| bus.id.eq_ignore_ascii_case(&s.bus) && bus.grounded.contains(reference))
52 })
53 }) {
54 return Err(powerio_core::Error::new(
55 &crate::diagnostics::codes::BUILD_DIST_ELECTRICAL_INCOMPLETE,
56 format!(
57 "source `{}`: this projection does not support reference-terminal voltage constraints",
58 source.name
59 ),
60 ));
61 }
62 Ok(())
63}
64
65pub(crate) fn require_resolved_geometry(
69 net: &MulticonductorNetwork,
70) -> Result<(), powerio_core::Error> {
71 let report = audit_electrical_readiness(net);
72 if let Some(finding) = report
73 .blockers()
74 .find(|finding| CONVERSION_BLOCKERS.contains(&finding.code))
75 {
76 return Err(powerio_core::Error::new(
77 &crate::diagnostics::codes::BUILD_DIST_ELECTRICAL_INCOMPLETE,
78 format!(
79 "{}: {}; retain the source module or provide explicit conductor impedances before canonical conversion",
80 finding.element, finding.message
81 ),
82 ));
83 }
84 Ok(())
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum ReadinessSeverity {
90 Blocker,
92 Warning,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct ReadinessFinding {
99 pub severity: ReadinessSeverity,
100 pub code: &'static str,
101 pub element: String,
102 pub message: String,
103}
104
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct ElectricalReadiness {
108 pub findings: Vec<ReadinessFinding>,
109}
110
111impl ElectricalReadiness {
112 #[must_use]
114 pub fn is_ready(&self) -> bool {
115 !self
116 .findings
117 .iter()
118 .any(|finding| finding.severity == ReadinessSeverity::Blocker)
119 }
120
121 pub fn blockers(&self) -> impl Iterator<Item = &ReadinessFinding> {
123 self.findings
124 .iter()
125 .filter(|finding| finding.severity == ReadinessSeverity::Blocker)
126 }
127
128 pub fn warnings(&self) -> impl Iterator<Item = &ReadinessFinding> {
130 self.findings
131 .iter()
132 .filter(|finding| finding.severity == ReadinessSeverity::Warning)
133 }
134
135 fn block(&mut self, code: &'static str, element: &str, message: impl Into<String>) {
136 self.findings.push(ReadinessFinding {
137 severity: ReadinessSeverity::Blocker,
138 code,
139 element: element.to_owned(),
140 message: message.into(),
141 });
142 }
143
144 fn warn(&mut self, code: &'static str, element: &str, message: impl Into<String>) {
145 self.findings.push(ReadinessFinding {
146 severity: ReadinessSeverity::Warning,
147 code,
148 element: element.to_owned(),
149 message: message.into(),
150 });
151 }
152}
153
154#[must_use]
165pub fn audit_electrical_readiness(net: &MulticonductorNetwork) -> ElectricalReadiness {
166 let mut report = ElectricalReadiness::default();
167
168 if !net.base_frequency().is_finite() || net.base_frequency() <= 0.0 {
169 report.block(
170 "READINESS.FREQUENCY.INVALID",
171 "network",
172 format!(
173 "base frequency must be finite and greater than zero; got {}",
174 net.base_frequency()
175 ),
176 );
177 }
178
179 let identity = |value: &str| {
180 if *net.source_format() == Some(crate::model::DistSourceFormat::Dss) {
181 value.to_ascii_lowercase()
182 } else {
183 value.to_owned()
184 }
185 };
186 audit_untyped(net, &mut report);
187 audit_load_voltage(net, &mut report);
188
189 for generator in net.generators() {
190 let model = generator.extras.get("model").and_then(|value| {
191 value
192 .as_f64()
193 .or_else(|| value.as_str().and_then(|s| s.parse::<f64>().ok()))
194 });
195 if model.is_some_and(|value| value.round() as i64 == 6) {
196 report.block("READINESS.DSS.GENERATOR_USER_MODEL_UNRESOLVED", &generator.name,
197 "generator model 6 has no typed voltage/current law; retained user-library settings and nominal P/Q do not establish an electrical equivalent");
198 }
199 }
200
201 for (element, fields) in net.defaulted() {
202 report.warn(
203 "READINESS.SOURCE.DEFAULTED",
204 element,
205 format!("source defaults supplied {}", fields.join(", ")),
206 );
207 }
208 let mut buses = BTreeMap::new();
209 for bus in net.buses() {
210 if buses.insert(identity(&bus.id), bus).is_some() {
211 report.block(
212 "READINESS.BUS.DUPLICATE",
213 &bus.id,
214 "bus identifier is duplicated under the source identifier convention",
215 );
216 }
217 if bus.terminals.is_empty() {
218 report.block(
219 "READINESS.BUS.TERMINALS_EMPTY",
220 &bus.id,
221 "bus has no terminals",
222 );
223 }
224 }
225
226 let mut linecodes = BTreeMap::new();
227 for code in net.line_codes() {
228 let key = identity(&code.name);
229 if linecodes.insert(key, code).is_some() {
230 report.block(
231 "READINESS.LINECODE.DUPLICATE",
232 &code.name,
233 "linecode name is duplicated under the source identifier convention",
234 );
235 }
236 audit_linecode(code, &mut report);
237 }
238
239 audit_lines(net, &buses, &linecodes, identity, &mut report);
240
241 for source in net.sources() {
242 let Some(reference) = &source.reference_terminal else {
243 continue;
244 };
245 let declared = buses.get(&identity(&source.bus)).is_some_and(|bus| {
246 bus.terminals.contains(reference)
247 && source
248 .terminal_map
249 .iter()
250 .all(|t| bus.terminals.contains(t))
251 });
252 let n = source.terminal_map.len();
253 if !declared
254 || n == 0
255 || source.terminal_map.contains(reference)
256 || source.terminal_map.iter().collect::<BTreeSet<_>>().len() != n
257 || source.v_magnitude.len() != n
258 || source.v_angle.len() != n
259 || source
260 .v_magnitude
261 .iter()
262 .any(|v| !v.is_finite() || *v < 0.0)
263 || source.v_angle.iter().any(|v| !v.is_finite())
264 {
265 report.block("READINESS.SOURCE.REFERENCE_INVALID", &source.name,
266 "referenced source needs distinct declared terminals and complete finite voltage differences");
267 }
268 }
269
270 report
271}
272
273fn audit_untyped(net: &MulticonductorNetwork, report: &mut ElectricalReadiness) {
274 let autotrans_state_command = net.commands().iter().any(|(verb, args)| {
275 matches!(verb.as_str(), "enable" | "disable" | "open" | "close")
276 && crate::dss::lex::Scanner::new(args, None)
277 .next_param()
278 .is_some_and(|p| p.value.text.to_ascii_lowercase().starts_with("autotrans."))
279 });
280 let reactor_state_command = net.commands().iter().any(|(verb, args)| {
281 matches!(verb.as_str(), "enable" | "disable" | "open" | "close")
282 && crate::dss::lex::Scanner::new(args, None)
283 .next_param()
284 .is_some_and(|p| p.value.text.to_ascii_lowercase().starts_with("reactor."))
285 });
286 let capacitor_state_command = net.commands().iter().any(|(verb, args)| {
287 matches!(verb.as_str(), "enable" | "disable" | "open" | "close")
288 && crate::dss::lex::Scanner::new(args, None)
289 .next_param()
290 .is_some_and(|p| p.value.text.to_ascii_lowercase().starts_with("capacitor."))
291 });
292 let storage_state_command = net.commands().iter().any(|(verb, args)| {
293 matches!(verb.as_str(), "enable" | "disable" | "open" | "close")
294 && crate::dss::lex::Scanner::new(args, None)
295 .next_param()
296 .is_some_and(|p| p.value.text.to_ascii_lowercase().starts_with("storage."))
297 });
298 for object in net.untyped_objects() {
299 let explicitly_disabled = object
300 .props
301 .iter()
302 .rev()
303 .find(|(key, _)| {
304 key.as_ref()
305 .is_some_and(|key| key.eq_ignore_ascii_case("enabled"))
306 })
307 .is_some_and(|(_, value)| {
308 value.eq_ignore_ascii_case("no") || value.eq_ignore_ascii_case("false")
309 });
310 if object.class.eq_ignore_ascii_case("autotrans")
311 && (!explicitly_disabled || autotrans_state_command)
312 {
313 report.block("READINESS.DSS.AUTOTRANS_UNRESOLVED", &object.name,
314 "untyped autotransformer has unresolved winding behavior; omitting it can disconnect the feeder");
315 }
316 if object.class.eq_ignore_ascii_case("reactor")
317 && (!explicitly_disabled || reactor_state_command)
318 {
319 report.block("READINESS.DSS.REACTOR_UNRESOLVED", &object.name,
320 "untyped reactor has unresolved electrical behavior; omitting a series reactor can disconnect the feeder");
321 }
322 if object.class.eq_ignore_ascii_case("capacitor")
323 && (!explicitly_disabled || capacitor_state_command)
324 {
325 report.block("READINESS.DSS.CAPACITOR_UNRESOLVED", &object.name,
326 "untyped capacitor has unresolved electrical behavior; omitting it changes the feeder");
327 }
328 if object.class.eq_ignore_ascii_case("storage")
329 && (!explicitly_disabled || storage_state_command)
330 {
331 report.block(
332 "READINESS.DSS.STORAGE_UNRESOLVED",
333 &object.name,
334 "untyped storage has unresolved static power exchange, including idling losses",
335 );
336 }
337 if object.class.eq_ignore_ascii_case("fault") && !explicitly_disabled {
338 report.block("READINESS.DSS.FAULT_UNRESOLVED", &object.name,
339 "enabled fault has unresolved static admittance and switching state; omitting it changes the circuit");
340 }
341 if object.class.eq_ignore_ascii_case("vsource") && !explicitly_disabled {
342 report.block("READINESS.DSS.SOURCE_UNRESOLVED", &object.name,
343 "untyped voltage source must remain explicitly disabled; enabling it requires electrical source conversion");
344 }
345 if object.class.eq_ignore_ascii_case("line")
346 && !explicitly_disabled
347 && object.props.iter().any(|(key, _)| {
348 key.as_ref().is_some_and(|key| {
349 DEFERRED_GEOMETRY_KEYS
350 .iter()
351 .any(|candidate| key.eq_ignore_ascii_case(candidate))
352 })
353 })
354 {
355 report.block("READINESS.DSS.GEOMETRY_DEFERRED", &object.name,
356 "source line geometry has no calculated conductor impedances or resolved terminal map");
357 } else if object.class.eq_ignore_ascii_case("line") && !explicitly_disabled {
358 report.block("READINESS.DSS.LINE_UNRESOLVED", &object.name,
359 "untyped line must remain explicitly disabled; enabling it requires electrical line conversion");
360 }
361 }
362}
363
364fn audit_lines(
365 net: &MulticonductorNetwork,
366 buses: &BTreeMap<String, &crate::model::DistBus>,
367 linecodes: &BTreeMap<String, &DistLineCode>,
368 identity: impl Fn(&str) -> String,
369 report: &mut ElectricalReadiness,
370) {
371 let mut lines = BTreeSet::new();
372 for line in net.lines() {
373 if !lines.insert(identity(&line.name)) {
374 report.block(
375 "READINESS.LINE.DUPLICATE",
376 &line.name,
377 "line identifier is duplicated",
378 );
379 }
380 for (bus_id, terminals) in [
381 (&line.bus_from, &line.terminal_map_from),
382 (&line.bus_to, &line.terminal_map_to),
383 ] {
384 if let Some(bus) = buses.get(&identity(bus_id)) {
385 for terminal in terminals {
386 if !bus.terminals.contains(terminal) {
387 report.block(
388 "READINESS.LINE.TERMINAL_UNRESOLVED",
389 &line.name,
390 format!("bus {bus_id} does not declare terminal {terminal}"),
391 );
392 }
393 }
394 }
395 }
396 if !line.length.is_finite() || line.length <= 0.0 {
397 report.block(
398 "READINESS.LINE.LENGTH_INVALID",
399 &line.name,
400 format!(
401 "line length must be finite and greater than zero; got {}",
402 line.length
403 ),
404 );
405 }
406
407 if !buses.contains_key(&identity(&line.bus_from)) {
408 report.block(
409 "READINESS.LINE.BUS_FROM_UNRESOLVED",
410 &line.name,
411 format!("from-bus {:?} does not exist", line.bus_from),
412 );
413 }
414 if !buses.contains_key(&identity(&line.bus_to)) {
415 report.block(
416 "READINESS.LINE.BUS_TO_UNRESOLVED",
417 &line.name,
418 format!("to-bus {:?} does not exist", line.bus_to),
419 );
420 }
421
422 audit_deferred_geometry(line, report);
423 if line
424 .extras
425 .get(UNRESOLVED_IMPEDANCE_KEY)
426 .is_some_and(|v| v.as_bool() == Some(true))
427 {
428 report.block(
429 "READINESS.CIM_DIST.IMPEDANCE_UNRESOLVED",
430 &line.name,
431 "the CIM source states no impedance the reader can use; its zero placeholder \
432 impedance would be a short circuit",
433 );
434 }
435
436 let Some(code) = linecodes.get(&identity(&line.linecode)) else {
437 report.block(
438 "READINESS.LINE.LINECODE_UNRESOLVED",
439 &line.name,
440 format!("linecode {:?} does not exist", line.linecode),
441 );
442 continue;
443 };
444
445 if line.terminal_map_from.len() != code.n_conductors
446 || line.terminal_map_to.len() != code.n_conductors
447 {
448 report.block(
449 "READINESS.LINE.TERMINAL_COUNT_MISMATCH",
450 &line.name,
451 format!(
452 "terminal maps have lengths {}/{} but linecode requires {} conductors",
453 line.terminal_map_from.len(),
454 line.terminal_map_to.len(),
455 code.n_conductors
456 ),
457 );
458 }
459 }
460}
461
462fn audit_deferred_geometry(line: &crate::model::DistLine, report: &mut ElectricalReadiness) {
463 let keys: Vec<&str> = DEFERRED_GEOMETRY_KEYS
464 .into_iter()
465 .filter(|key| line.extras.contains_key(*key))
466 .collect();
467
468 if !keys.is_empty() {
469 report.block(
470 "READINESS.DSS.GEOMETRY_DEFERRED",
471 &line.name,
472 format!(
473 "OpenDSS geometry-family properties {keys:?} are deferred; no geometry-derived impedance may be assumed"
474 ),
475 );
476 }
477}
478
479fn audit_linecode(code: &DistLineCode, report: &mut ElectricalReadiness) {
480 if code.n_conductors == 0 {
481 report.block(
482 "READINESS.LINECODE.CONDUCTOR_COUNT_ZERO",
483 &code.name,
484 "linecode has zero conductors",
485 );
486 return;
487 }
488
489 audit_square_matrix(
490 &code.name,
491 "r_series",
492 &code.r_series,
493 code.n_conductors,
494 report,
495 );
496 audit_square_matrix(
497 &code.name,
498 "x_series",
499 &code.x_series,
500 code.n_conductors,
501 report,
502 );
503 audit_square_matrix(
504 &code.name,
505 "g_from",
506 &code.g_from,
507 code.n_conductors,
508 report,
509 );
510 audit_square_matrix(
511 &code.name,
512 "b_from",
513 &code.b_from,
514 code.n_conductors,
515 report,
516 );
517 audit_square_matrix(&code.name, "g_to", &code.g_to, code.n_conductors, report);
518 audit_square_matrix(&code.name, "b_to", &code.b_to, code.n_conductors, report);
519}
520
521fn audit_square_matrix(
522 element: &str,
523 field: &str,
524 matrix: &[Vec<f64>],
525 n: usize,
526 report: &mut ElectricalReadiness,
527) {
528 if matrix.len() != n || matrix.iter().any(|row| row.len() != n) {
529 report.block(
530 "READINESS.MATRIX.SHAPE_MISMATCH",
531 element,
532 format!("{field} must be a {n}x{n} matrix"),
533 );
534 return;
535 }
536 if matrix.iter().flatten().any(|value| !value.is_finite()) {
537 report.block(
538 "READINESS.MATRIX.NONFINITE",
539 element,
540 format!("{field} contains a non-finite value"),
541 );
542 }
543}
544
545fn audit_load_voltage(net: &MulticonductorNetwork, report: &mut ElectricalReadiness) {
546 for load in net.loads() {
547 if matches!(
548 load.voltage_model,
549 crate::DistLoadVoltageModel::BoundedZip { .. }
550 | crate::DistLoadVoltageModel::BoundedExponential { .. }
551 | crate::DistLoadVoltageModel::BoundedConstantPower { .. }
552 ) {
553 let n = load.p_nom.len().max(load.q_nom.len());
554 if n == 0 || load.voltage_model.v_nom().len() != n {
555 report.block(
556 "READINESS.LOAD.VOLTAGE_LAW",
557 &load.name,
558 "bounded load law must align with nominal power branches",
559 );
560 }
561 for phase in 0..n {
562 let v = load
563 .voltage_model
564 .v_nom()
565 .get(phase)
566 .copied()
567 .unwrap_or(f64::NAN);
568 if let Err(error) = load.voltage_model.power_scale(phase, v) {
569 report.block("READINESS.LOAD.VOLTAGE_LAW", &load.name, error);
570 break;
571 }
572 }
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::{DistBus, DistLine, DistLineCode, Extras, MulticonductorNetwork};
581
582 #[test]
583 fn unresolved_series_reactor_is_a_blocker_unless_disabled() {
584 let mut net = MulticonductorNetwork::new();
585 net.untyped_objects_mut()
586 .push(crate::model::UntypedObject::new(
587 "reactor",
588 "feed",
589 vec![
590 (Some("bus1".into()), "source".into()),
591 (Some("bus2".into()), "feeder".into()),
592 (Some("x".into()), "15".into()),
593 ],
594 ));
595 let report = audit_electrical_readiness(&net);
596 assert!(
597 report
598 .blockers()
599 .any(|f| f.code == "READINESS.DSS.REACTOR_UNRESOLVED")
600 );
601 assert!(require_electrical_readiness(&net).is_err());
602 net.untyped_objects_mut()[0]
603 .props
604 .push((Some("enabled".into()), "no".into()));
605 assert!(audit_electrical_readiness(&net).is_ready());
606 }
607
608 #[test]
609 fn inactive_source_metadata_cannot_be_reenabled_without_electrical_conversion() {
610 let mut net = MulticonductorNetwork::new();
611 net.untyped_objects_mut()
612 .push(crate::model::UntypedObject::new(
613 "vsource",
614 "off",
615 vec![(Some("enabled".into()), "no".into())],
616 ));
617 assert!(audit_electrical_readiness(&net).is_ready());
618 net.untyped_objects_mut()[0]
619 .props
620 .push((Some("enabled".into()), "yes".into()));
621 assert!(!audit_electrical_readiness(&net).is_ready());
622 net.untyped_objects_mut()[0].props.clear();
623 assert!(!audit_electrical_readiness(&net).is_ready());
624 }
625
626 #[test]
627 fn grounded_voltage_reference_is_earth_but_a_floating_reference_is_not() {
628 let mut net = MulticonductorNetwork::new();
629 let mut bus = DistBus::new("feed", vec!["1".into(), "4".into()]);
630 bus.grounded.push("4".into());
631 net.buses_mut().push(bus);
632 let mut source =
633 crate::VoltageSource::new("s", "feed", vec!["1".into()], vec![240.0], vec![0.0]);
634 source.reference_terminal = Some("4".into());
635 net.sources_mut().push(source);
636 assert!(require_earth_referenced_sources(&net).is_ok());
637 net.buses_mut()[0].grounded.clear();
638 assert!(require_earth_referenced_sources(&net).is_err());
639 }
640
641 #[test]
642 fn inactive_line_metadata_cannot_be_reenabled_without_electrical_conversion() {
643 let mut net = MulticonductorNetwork::new();
644 net.untyped_objects_mut()
645 .push(crate::model::UntypedObject::new(
646 "line",
647 "off",
648 vec![(Some("enabled".into()), "no".into())],
649 ));
650 assert!(audit_electrical_readiness(&net).is_ready());
651 net.untyped_objects_mut()[0]
652 .props
653 .push((Some("enabled".into()), "yes".into()));
654 let report = audit_electrical_readiness(&net);
655 assert!(
656 report
657 .blockers()
658 .any(|f| f.code == "READINESS.DSS.LINE_UNRESOLVED")
659 );
660 net.untyped_objects_mut()[0].props.clear();
661 assert!(!audit_electrical_readiness(&net).is_ready());
662 }
663
664 fn network_with_line(extras: Extras) -> MulticonductorNetwork {
665 let mut net = MulticonductorNetwork::new();
666 net.buses_mut()
667 .push(DistBus::new("source", vec!["1".into()]));
668 net.buses_mut().push(DistBus::new("load", vec!["1".into()]));
669 net.line_codes_mut().push(DistLineCode::new(
670 "explicit",
671 vec![vec![0.1]],
672 vec![vec![0.2]],
673 ));
674 let mut line = DistLine::new(
675 "l1",
676 "source",
677 "load",
678 vec!["1".into()],
679 vec!["1".into()],
680 "explicit",
681 100.0,
682 );
683 line.extras = extras;
684 net.lines_mut().push(line);
685 net
686 }
687
688 #[test]
689 fn explicit_line_is_ready() {
690 let report = audit_electrical_readiness(&network_with_line(Extras::new()));
691 assert!(report.is_ready());
692 assert_eq!(report.blockers().count(), 0);
693 }
694
695 #[test]
696 fn geometry_metadata_is_a_hard_blocker() {
697 let mut extras = Extras::new();
698 extras.insert("geometry".into(), serde_json::json!("g601"));
699 let report = audit_electrical_readiness(&network_with_line(extras));
700 assert!(!report.is_ready());
701 assert!(
702 report
703 .blockers()
704 .any(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
705 );
706 }
707
708 #[test]
709 fn all_deferred_geometry_families_are_hard_blockers() {
710 for key in ["geometry", "spacing", "wires", "cncables", "tscables"] {
711 let mut extras = Extras::new();
712 extras.insert(key.into(), serde_json::json!("deferred"));
713 let report = audit_electrical_readiness(&network_with_line(extras));
714 assert!(!report.is_ready(), "{key} must block readiness");
715 assert_eq!(
716 report
717 .blockers()
718 .filter(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
719 .count(),
720 1,
721 "{key} must produce exactly one deferred-geometry blocker"
722 );
723 }
724 }
725
726 #[test]
727 fn swer_geometry_cannot_be_hidden_by_a_one_phase_linecode() {
728 let mut extras = Extras::new();
729 extras.insert("geometry".into(), serde_json::json!("gswer"));
730 let mut net = network_with_line(extras);
731 net.lines_mut()[0].terminal_map_from = vec!["1".into()];
732 net.lines_mut()[0].terminal_map_to = vec!["1".into()];
733 let report = audit_electrical_readiness(&net);
734 assert!(!report.is_ready());
735 assert!(
736 report
737 .blockers()
738 .any(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
739 );
740 }
741
742 #[test]
743 fn unresolved_cim_impedance_blocks_conversion() {
744 let mut extras = Extras::new();
745 extras.insert(UNRESOLVED_IMPEDANCE_KEY.into(), serde_json::json!(true));
746 let net = network_with_line(extras);
747 let report = audit_electrical_readiness(&net);
748 assert!(
749 report
750 .blockers()
751 .any(|finding| finding.code == "READINESS.CIM_DIST.IMPEDANCE_UNRESOLVED")
752 );
753 assert!(require_resolved_geometry(&net).is_err());
754 }
755
756 #[test]
757 fn malformed_impedance_matrix_blocks() {
758 let mut net = network_with_line(Extras::new());
759 net.line_codes_mut()[0].n_conductors = 2;
760 let report = audit_electrical_readiness(&net);
761 assert!(!report.is_ready());
762 assert!(
763 report
764 .blockers()
765 .any(|finding| finding.code == "READINESS.MATRIX.SHAPE_MISMATCH")
766 );
767 }
768}