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. In particular, OpenDSS line geometry is
6//! still deferred: until Carson/geometry lowering exists, 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/// Reject incomplete electrical data before numerical use.
18pub fn require_electrical_readiness(
19    net: &MulticonductorNetwork,
20) -> Result<(), powerio_core::Error> {
21    let report = audit_electrical_readiness(net);
22    if let Some(finding) = report.blockers().next() {
23        return Err(powerio_core::Error::new(
24            &crate::diagnostics::codes::BUILD_DIST_ELECTRICAL_INCOMPLETE,
25            format!("{} {}: {}", finding.code, finding.element, finding.message),
26        ));
27    }
28    Ok(())
29}
30
31/// Reject source-only electrical equipment that a canonical writer cannot reconstruct.
32pub(crate) fn require_resolved_geometry(
33    net: &MulticonductorNetwork,
34) -> Result<(), powerio_core::Error> {
35    let report = audit_electrical_readiness(net);
36    if let Some(finding) = report
37        .blockers()
38        .find(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
39    {
40        return Err(powerio_core::Error::new(
41            &crate::diagnostics::codes::BUILD_DIST_ELECTRICAL_INCOMPLETE,
42            format!(
43                "{}: {}; retain the source module or provide explicit conductor impedances before canonical conversion",
44                finding.element, finding.message
45            ),
46        ));
47    }
48    Ok(())
49}
50
51/// Severity of an electrical-readiness finding.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ReadinessSeverity {
54    /// The network must not be passed to numerical lowering or solving.
55    Blocker,
56    /// The network can be used, but the caller should inspect the finding.
57    Warning,
58}
59
60/// A deterministic finding emitted by the readiness audit.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct ReadinessFinding {
63    pub severity: ReadinessSeverity,
64    pub code: &'static str,
65    pub element: String,
66    pub message: String,
67}
68
69/// Result of [`audit_electrical_readiness`].
70#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub struct ElectricalReadiness {
72    pub findings: Vec<ReadinessFinding>,
73}
74
75impl ElectricalReadiness {
76    /// Returns `true` only when no blocker is present.
77    #[must_use]
78    pub fn is_ready(&self) -> bool {
79        !self
80            .findings
81            .iter()
82            .any(|finding| finding.severity == ReadinessSeverity::Blocker)
83    }
84
85    /// Returns the findings that make the network unsafe to solve.
86    pub fn blockers(&self) -> impl Iterator<Item = &ReadinessFinding> {
87        self.findings
88            .iter()
89            .filter(|finding| finding.severity == ReadinessSeverity::Blocker)
90    }
91
92    /// Returns non-fatal findings.
93    pub fn warnings(&self) -> impl Iterator<Item = &ReadinessFinding> {
94        self.findings
95            .iter()
96            .filter(|finding| finding.severity == ReadinessSeverity::Warning)
97    }
98
99    fn block(&mut self, code: &'static str, element: &str, message: impl Into<String>) {
100        self.findings.push(ReadinessFinding {
101            severity: ReadinessSeverity::Blocker,
102            code,
103            element: element.to_owned(),
104            message: message.into(),
105        });
106    }
107
108    fn warn(&mut self, code: &'static str, element: &str, message: impl Into<String>) {
109        self.findings.push(ReadinessFinding {
110            severity: ReadinessSeverity::Warning,
111            code,
112            element: element.to_owned(),
113            message: message.into(),
114        });
115    }
116}
117
118/// Audit a multiconductor network before numerical lowering or solving.
119///
120/// This check is intentionally conservative and read-only. It does not repair
121/// topology, invent conductor counts, or replace missing electrical data with
122/// source-format defaults. OpenDSS `geometry=`, `spacing=`, `wires=`,
123/// `cncables=`, and `tscables=` metadata are treated as a hard blocker because
124/// the geometry family is not yet lowered into the canonical impedance
125/// matrices. The audit therefore provides an explicit fail-closed boundary
126/// for applications that need to decide whether a parsed network is safe to
127/// analyse.
128#[must_use]
129pub fn audit_electrical_readiness(net: &MulticonductorNetwork) -> ElectricalReadiness {
130    let mut report = ElectricalReadiness::default();
131
132    if !net.base_frequency().is_finite() || net.base_frequency() <= 0.0 {
133        report.block(
134            "READINESS.FREQUENCY.INVALID",
135            "network",
136            format!(
137                "base frequency must be finite and greater than zero; got {}",
138                net.base_frequency()
139            ),
140        );
141    }
142
143    let identity = |value: &str| {
144        if *net.source_format() == Some(crate::model::DistSourceFormat::Dss) {
145            value.to_ascii_lowercase()
146        } else {
147            value.to_owned()
148        }
149    };
150    for object in net.untyped_objects() {
151        if object.class.eq_ignore_ascii_case("line")
152            && object.props.iter().any(|(key, _)| {
153                key.as_ref().is_some_and(|key| {
154                    DEFERRED_GEOMETRY_KEYS
155                        .iter()
156                        .any(|candidate| key.eq_ignore_ascii_case(candidate))
157                })
158            })
159        {
160            report.block("READINESS.DSS.GEOMETRY_DEFERRED", &object.name,
161                "source line geometry has no calculated conductor impedances or resolved terminal map");
162        }
163    }
164    for (element, fields) in net.defaulted() {
165        report.warn(
166            "READINESS.SOURCE.DEFAULTED",
167            element,
168            format!("source defaults supplied {}", fields.join(", ")),
169        );
170    }
171    let mut buses = BTreeMap::new();
172    for bus in net.buses() {
173        if buses.insert(identity(&bus.id), bus).is_some() {
174            report.block(
175                "READINESS.BUS.DUPLICATE",
176                &bus.id,
177                "bus identifier is duplicated under the source identifier convention",
178            );
179        }
180        if bus.terminals.is_empty() {
181            report.block(
182                "READINESS.BUS.TERMINALS_EMPTY",
183                &bus.id,
184                "bus has no terminals",
185            );
186        }
187    }
188
189    let mut linecodes = BTreeMap::new();
190    for code in net.line_codes() {
191        let key = identity(&code.name);
192        if linecodes.insert(key, code).is_some() {
193            report.block(
194                "READINESS.LINECODE.DUPLICATE",
195                &code.name,
196                "linecode name is duplicated under the source identifier convention",
197            );
198        }
199        audit_linecode(code, &mut report);
200    }
201
202    audit_lines(net, &buses, &linecodes, identity, &mut report);
203
204    report
205}
206
207fn audit_lines(
208    net: &MulticonductorNetwork,
209    buses: &BTreeMap<String, &crate::model::DistBus>,
210    linecodes: &BTreeMap<String, &DistLineCode>,
211    identity: impl Fn(&str) -> String,
212    report: &mut ElectricalReadiness,
213) {
214    let mut lines = BTreeSet::new();
215    for line in net.lines() {
216        if !lines.insert(identity(&line.name)) {
217            report.block(
218                "READINESS.LINE.DUPLICATE",
219                &line.name,
220                "line identifier is duplicated",
221            );
222        }
223        for (bus_id, terminals) in [
224            (&line.bus_from, &line.terminal_map_from),
225            (&line.bus_to, &line.terminal_map_to),
226        ] {
227            if let Some(bus) = buses.get(&identity(bus_id)) {
228                for terminal in terminals {
229                    if !bus.terminals.contains(terminal) {
230                        report.block(
231                            "READINESS.LINE.TERMINAL_UNRESOLVED",
232                            &line.name,
233                            format!("bus {bus_id} does not declare terminal {terminal}"),
234                        );
235                    }
236                }
237            }
238        }
239        if !line.length.is_finite() || line.length <= 0.0 {
240            report.block(
241                "READINESS.LINE.LENGTH_INVALID",
242                &line.name,
243                format!(
244                    "line length must be finite and greater than zero; got {}",
245                    line.length
246                ),
247            );
248        }
249
250        if !buses.contains_key(&identity(&line.bus_from)) {
251            report.block(
252                "READINESS.LINE.BUS_FROM_UNRESOLVED",
253                &line.name,
254                format!("from-bus {:?} does not exist", line.bus_from),
255            );
256        }
257        if !buses.contains_key(&identity(&line.bus_to)) {
258            report.block(
259                "READINESS.LINE.BUS_TO_UNRESOLVED",
260                &line.name,
261                format!("to-bus {:?} does not exist", line.bus_to),
262            );
263        }
264
265        audit_deferred_geometry(line, report);
266
267        let Some(code) = linecodes.get(&identity(&line.linecode)) else {
268            report.block(
269                "READINESS.LINE.LINECODE_UNRESOLVED",
270                &line.name,
271                format!("linecode {:?} does not exist", line.linecode),
272            );
273            continue;
274        };
275
276        if line.terminal_map_from.len() != code.n_conductors
277            || line.terminal_map_to.len() != code.n_conductors
278        {
279            report.block(
280                "READINESS.LINE.TERMINAL_COUNT_MISMATCH",
281                &line.name,
282                format!(
283                    "terminal maps have lengths {}/{} but linecode requires {} conductors",
284                    line.terminal_map_from.len(),
285                    line.terminal_map_to.len(),
286                    code.n_conductors
287                ),
288            );
289        }
290    }
291}
292
293fn audit_deferred_geometry(line: &crate::model::DistLine, report: &mut ElectricalReadiness) {
294    let keys: Vec<&str> = DEFERRED_GEOMETRY_KEYS
295        .into_iter()
296        .filter(|key| line.extras.contains_key(*key))
297        .collect();
298
299    if !keys.is_empty() {
300        report.block(
301            "READINESS.DSS.GEOMETRY_DEFERRED",
302            &line.name,
303            format!(
304                "OpenDSS geometry-family properties {keys:?} are deferred; no geometry-derived impedance may be assumed"
305            ),
306        );
307    }
308}
309
310fn audit_linecode(code: &DistLineCode, report: &mut ElectricalReadiness) {
311    if code.n_conductors == 0 {
312        report.block(
313            "READINESS.LINECODE.CONDUCTOR_COUNT_ZERO",
314            &code.name,
315            "linecode has zero conductors",
316        );
317        return;
318    }
319
320    audit_square_matrix(
321        &code.name,
322        "r_series",
323        &code.r_series,
324        code.n_conductors,
325        report,
326    );
327    audit_square_matrix(
328        &code.name,
329        "x_series",
330        &code.x_series,
331        code.n_conductors,
332        report,
333    );
334    audit_square_matrix(
335        &code.name,
336        "g_from",
337        &code.g_from,
338        code.n_conductors,
339        report,
340    );
341    audit_square_matrix(
342        &code.name,
343        "b_from",
344        &code.b_from,
345        code.n_conductors,
346        report,
347    );
348    audit_square_matrix(&code.name, "g_to", &code.g_to, code.n_conductors, report);
349    audit_square_matrix(&code.name, "b_to", &code.b_to, code.n_conductors, report);
350}
351
352fn audit_square_matrix(
353    element: &str,
354    field: &str,
355    matrix: &[Vec<f64>],
356    n: usize,
357    report: &mut ElectricalReadiness,
358) {
359    if matrix.len() != n || matrix.iter().any(|row| row.len() != n) {
360        report.block(
361            "READINESS.MATRIX.SHAPE_MISMATCH",
362            element,
363            format!("{field} must be a {n}x{n} matrix"),
364        );
365        return;
366    }
367    if matrix.iter().flatten().any(|value| !value.is_finite()) {
368        report.block(
369            "READINESS.MATRIX.NONFINITE",
370            element,
371            format!("{field} contains a non-finite value"),
372        );
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::{DistBus, DistLine, DistLineCode, Extras, MulticonductorNetwork};
380
381    fn network_with_line(extras: Extras) -> MulticonductorNetwork {
382        let mut net = MulticonductorNetwork::new();
383        net.buses_mut()
384            .push(DistBus::new("source", vec!["1".into()]));
385        net.buses_mut().push(DistBus::new("load", vec!["1".into()]));
386        net.line_codes_mut().push(DistLineCode::new(
387            "explicit",
388            vec![vec![0.1]],
389            vec![vec![0.2]],
390        ));
391        let mut line = DistLine::new(
392            "l1",
393            "source",
394            "load",
395            vec!["1".into()],
396            vec!["1".into()],
397            "explicit",
398            100.0,
399        );
400        line.extras = extras;
401        net.lines_mut().push(line);
402        net
403    }
404
405    #[test]
406    fn explicit_line_is_ready() {
407        let report = audit_electrical_readiness(&network_with_line(Extras::new()));
408        assert!(report.is_ready());
409        assert_eq!(report.blockers().count(), 0);
410    }
411
412    #[test]
413    fn geometry_metadata_is_a_hard_blocker() {
414        let mut extras = Extras::new();
415        extras.insert("geometry".into(), serde_json::json!("g601"));
416        let report = audit_electrical_readiness(&network_with_line(extras));
417        assert!(!report.is_ready());
418        assert!(
419            report
420                .blockers()
421                .any(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
422        );
423    }
424
425    #[test]
426    fn all_deferred_geometry_families_are_hard_blockers() {
427        for key in ["geometry", "spacing", "wires", "cncables", "tscables"] {
428            let mut extras = Extras::new();
429            extras.insert(key.into(), serde_json::json!("deferred"));
430            let report = audit_electrical_readiness(&network_with_line(extras));
431            assert!(!report.is_ready(), "{key} must block readiness");
432            assert_eq!(
433                report
434                    .blockers()
435                    .filter(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
436                    .count(),
437                1,
438                "{key} must produce exactly one deferred-geometry blocker"
439            );
440        }
441    }
442
443    #[test]
444    fn swer_geometry_cannot_be_hidden_by_a_one_phase_linecode() {
445        let mut extras = Extras::new();
446        extras.insert("geometry".into(), serde_json::json!("gswer"));
447        let mut net = network_with_line(extras);
448        net.lines_mut()[0].terminal_map_from = vec!["1".into()];
449        net.lines_mut()[0].terminal_map_to = vec!["1".into()];
450        let report = audit_electrical_readiness(&net);
451        assert!(!report.is_ready());
452        assert!(
453            report
454                .blockers()
455                .any(|finding| finding.code == "READINESS.DSS.GEOMETRY_DEFERRED")
456        );
457    }
458
459    #[test]
460    fn malformed_impedance_matrix_blocks() {
461        let mut net = network_with_line(Extras::new());
462        net.line_codes_mut()[0].n_conductors = 2;
463        let report = audit_electrical_readiness(&net);
464        assert!(!report.is_ready());
465        assert!(
466            report
467                .blockers()
468                .any(|finding| finding.code == "READINESS.MATRIX.SHAPE_MISMATCH")
469        );
470    }
471}