Skip to main content

powerio_dist/
readiness.rs

1//! Pre-solver structural readiness checks for distribution networks.
2//!
3//! Parsing and electrical readiness are deliberately separate. A source can be
4//! syntactically valid while the resulting typed network is not safe to hand
5//! to a solver or semantic writer. Unresolved OpenDSS geometry metadata
6//! must remain blocked: a geometry-defined
7//! line must not be treated as electrically complete merely because the
8//! parser has a placeholder linecode.
9
10use 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
17/// The line extra the CIM reader sets on a segment whose source states no
18/// impedance it can read. The segment's line code is a zero placeholder.
19pub(crate) const UNRESOLVED_IMPEDANCE_KEY: &str = "cim_impedance_unresolved";
20
21/// Blocker codes that stop canonical conversion: the line exists, but its
22/// impedance is not in the canonical matrices.
23const CONVERSION_BLOCKERS: [&str; 2] = [
24    "READINESS.DSS.GEOMETRY_DEFERRED",
25    "READINESS.CIM_DIST.IMPEDANCE_UNRESOLVED",
26];
27
28/// Reject incomplete electrical data before numerical use.
29pub 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
42/// Refuse a projection that has not implemented terminal-referenced sources.
43/// Consumers that support them must instead retain both voltage endpoints.
44pub 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
65/// Reject source-only electrical equipment that a canonical writer cannot
66/// reconstruct: OpenDSS geometry lines, and CIM segments whose impedance the
67/// reader could not read.
68pub(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/// Severity of an electrical-readiness finding.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum ReadinessSeverity {
90    /// The network must not be passed to numerical lowering or solving.
91    Blocker,
92    /// The network can be used, but the caller should inspect the finding.
93    Warning,
94}
95
96/// A deterministic finding emitted by the readiness audit.
97#[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/// Result of [`audit_electrical_readiness`].
106#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct ElectricalReadiness {
108    pub findings: Vec<ReadinessFinding>,
109}
110
111impl ElectricalReadiness {
112    /// Returns `true` only when no blocker is present.
113    #[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    /// Returns the findings that make the network unsafe to solve.
122    pub fn blockers(&self) -> impl Iterator<Item = &ReadinessFinding> {
123        self.findings
124            .iter()
125            .filter(|finding| finding.severity == ReadinessSeverity::Blocker)
126    }
127
128    /// Returns non-fatal findings.
129    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/// Audit a multiconductor network before numerical lowering or solving.
155///
156/// This check is intentionally conservative and read-only. It does not repair
157/// topology, invent conductor counts, or replace missing electrical data with
158/// source-format defaults. OpenDSS `geometry=`, `spacing=`, `wires=`,
159/// `cncables=`, and `tscables=` metadata are treated as a hard blocker because
160/// those retained references have not been lowered into canonical impedance
161/// matrices. Resolved geometry carries explicit matrices instead. A CIM segment
162/// whose source states no readable impedance is a hard blocker for the same
163/// reason. The audit provides a fail-closed boundary for numerical consumers.
164#[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}