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 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
31pub(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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ReadinessSeverity {
54 Blocker,
56 Warning,
58}
59
60#[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub struct ElectricalReadiness {
72 pub findings: Vec<ReadinessFinding>,
73}
74
75impl ElectricalReadiness {
76 #[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 pub fn blockers(&self) -> impl Iterator<Item = &ReadinessFinding> {
87 self.findings
88 .iter()
89 .filter(|finding| finding.severity == ReadinessSeverity::Blocker)
90 }
91
92 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#[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}