From 2f9cd78cd9338f6203ee49ad56def288115a6aaf Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:39:20 -0400 Subject: [PATCH] Fail closed on deferred DSS geometry --- docs/dss-geometry-safety.md | 19 +- docs/src/distribution.md | 2 +- powerio-dist/src/bmopf/read.rs | 2 +- powerio-dist/src/bmopf/write.rs | 12 +- powerio-dist/src/diagnostics.rs | 11 + powerio-dist/src/dss/read.rs | 290 ++++++++++++++++------ powerio-dist/src/dss/write.rs | 32 ++- powerio-dist/src/model.rs | 68 +++-- powerio-dist/src/pmd/read.rs | 2 +- powerio-dist/src/pmd/write.rs | 23 +- powerio-dist/tests/bmopf.rs | 13 +- powerio-dist/tests/dss_geometry_safety.rs | 98 ++++++-- powerio-dist/tests/dss_reader.rs | 4 +- powerio-dist/tests/pmd.rs | 6 +- 14 files changed, 434 insertions(+), 148 deletions(-) diff --git a/docs/dss-geometry-safety.md b/docs/dss-geometry-safety.md index b4cb1b38d..be0e610a8 100644 --- a/docs/dss-geometry-safety.md +++ b/docs/dss-geometry-safety.md @@ -15,7 +15,8 @@ a geometry-defined line. Until Carson/geometry impedance calculation is implemented: -1. Detect a `Line` that references `geometry=`, `spacing=`, or `wires=`. +1. Detect a `Line` that references `geometry=`, `spacing=`, `wires=`, + `cncables=`, or `tscables=`. 2. Do not synthesize a `DistLineCode` from OpenDSS `Line` factory defaults. 3. Do not infer/fabricate conductor count from the balanced `phases` default. 4. Emit a **parse-time diagnostic** identifying the deferred geometry source. @@ -23,9 +24,12 @@ Until Carson/geometry impedance calculation is implemented: 6. Keep explicit `linecode=` behavior unchanged. 7. A one-conductor SWER geometry must never become a three-conductor normalized line. -The preferred long-term representation is an explicit unresolved/deferred line -reference in the canonical model. If that representation is not yet available, -the reader should fail closed rather than manufacture electrical parameters. +PowerIO represents this state explicitly: the `DistLine` remains in the +network, with its source-derived terminal maps and `linecode: None`. Parsing +records an error diagnostic because the typed network is not electrically +complete. A same-format DSS echo still returns the retained source exactly; +analysis and semantic format writers refuse the unresolved impedance, and no +writer supplies a placeholder linecode or electrical default. ## Regression cases @@ -33,12 +37,13 @@ The regression harness in `powerio-dist/tests/dss_geometry_safety.rs` covers: - a 3-phase overhead `LineGeometry` backed by `WireData`; - a single-conductor SWER `LineGeometry`; +- a 4-conductor `LineSpacing` plus `wires` definition; - parse-time diagnostics for deferred geometry; - protection against OpenDSS factory-default `R1` leaking into a geometry line. -The tests are currently `#[ignore]` because they encode the target invariant while -the canonical model's deferred-line representation is being finalized. They are -intended to become ordinary tests in the implementation PR. +These are ordinary regression tests. They also verify that retained DSS echoes +exactly and that every semantic output path reports unresolved impedance as an +error instead of inventing data. ## Upstream context diff --git a/docs/src/distribution.md b/docs/src/distribution.md index 52a175d26..4eba2e010 100644 --- a/docs/src/distribution.md +++ b/docs/src/distribution.md @@ -6,7 +6,7 @@ A multiconductor network is the conductor level distribution model. OpenDSS and using PowerIO feeder = parse_file("IEEE13Nodeckt.dss") # PioModule{MulticonductorNetwork} net = feeder.value -net.data.lines[1] # terminal maps, linecode reference +net.data.lines[1] # terminal maps, optional linecode reference net.data.linecodes[1] # per length impedance matrices, SI units diagnostics(feeder) # what the reader kept, assumed, or refused ``` diff --git a/powerio-dist/src/bmopf/read.rs b/powerio-dist/src/bmopf/read.rs index 19be74e43..c3e1777ba 100644 --- a/powerio-dist/src/bmopf/read.rs +++ b/powerio-dist/src/bmopf/read.rs @@ -1045,7 +1045,7 @@ impl Reader<'_> { bus_to: string(o.get("bus_to")), terminal_map_from: strings(o.get("terminal_map_from")), terminal_map_to: strings(o.get("terminal_map_to")), - linecode, + linecode: Some(linecode), length, route: None, i_max: floats(o.get("i_max")), diff --git a/powerio-dist/src/bmopf/write.rs b/powerio-dist/src/bmopf/write.rs index 8565746a9..bc616a1a2 100644 --- a/powerio-dist/src/bmopf/write.rs +++ b/powerio-dist/src/bmopf/write.rs @@ -806,9 +806,19 @@ impl Writer { if !net.lines().is_empty() { let mut lines = Map::new(); for l in net.lines() { + let Some(linecode) = l.linecode.as_deref() else { + self.warn( + &C::EMIT_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED, + format!( + "line {} omitted from BMOPF output: its impedance is unresolved", + l.name + ), + ); + continue; + }; let mut o = Map::new(); o.insert("length".into(), self.num(l.length, "line length")); - o.insert("linecode".into(), json!(l.linecode)); + o.insert("linecode".into(), json!(linecode)); o.insert("bus_from".into(), json!(l.bus_from)); o.insert("bus_to".into(), json!(l.bus_to)); o.insert("terminal_map_from".into(), json!(l.terminal_map_from)); diff --git a/powerio-dist/src/diagnostics.rs b/powerio-dist/src/diagnostics.rs index 160a435cc..b2f529342 100644 --- a/powerio-dist/src/diagnostics.rs +++ b/powerio-dist/src/diagnostics.rs @@ -66,6 +66,11 @@ pub mod codes { "the reader stopped following includes at the nesting depth limit"; READ_DSS_LINECODE_UNKNOWN = "READ.DSS.LINECODE_UNKNOWN", Warning, "a line names a linecode the case does not declare"; + /// Geometry-backed impedance remains in retained OpenDSS input but is + /// absent from the typed network until line constants are calculated. + /// Severity `Error`: electrical analysis of that network is unsafe. + READ_DSS_IMPEDANCE_UNRESOLVED = "READ.DSS.IMPEDANCE_UNRESOLVED", Error, + "a line's retained conductor geometry has not been lowered to impedance matrices"; // EMIT.DSS: what the canonical dss writer cannot state. EMIT_DSS_FIELD_DROPPED = "EMIT.DSS.FIELD_DROPPED", Warning, @@ -230,6 +235,12 @@ pub mod codes { "a line polyline was dropped because the target has no polyline field"; EMIT_MULTICONDUCTOR_SIDECAR_DROPPED = "EMIT.MULTICONDUCTOR.SIDECAR_DROPPED", Warning, "a companion file the case text refers to was not written"; + EMIT_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED = + "EMIT.MULTICONDUCTOR.IMPEDANCE_UNRESOLVED", Error, + "a line with unresolved impedance was omitted instead of receiving fabricated electrical values"; + VALIDATE_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED = + "VALIDATE.MULTICONDUCTOR.IMPEDANCE_UNRESOLVED", Error, + "a line has no resolved impedance and cannot be used for electrical analysis"; // Failures. READ_DIST_IO_FAILED = "READ.DIST.IO_FAILED", Error, diff --git a/powerio-dist/src/dss/read.rs b/powerio-dist/src/dss/read.rs index 643208d82..387ecf329 100644 --- a/powerio-dist/src/dss/read.rs +++ b/powerio-dist/src/dss/read.rs @@ -105,11 +105,23 @@ pub fn network_from_raw( bus_order: Vec::new(), linecode_units: BTreeMap::new(), linecode_nconds: BTreeMap::new(), + line_geometry_shapes: BTreeMap::new(), + line_spacing_shapes: BTreeMap::new(), xycurves: BTreeMap::new(), regulated: BTreeSet::new(), vars: &raw.vars, }; + // Geometry properties set a Line's conductor shape when they are applied. + // Index the final geometry objects before lines, just as linecodes are + // indexed before their references below. + for obj in raw.of_class("linegeometry") { + rd.remember_line_geometry(obj); + } + for obj in raw.of_class("linespacing") { + rd.remember_line_spacing(obj); + } + for (name, value) in &raw.options { // Set option names resolve by first match in the engine's option // table order (Command.cpp Getcommand → HashList FindAbbrev), so @@ -214,13 +226,9 @@ pub fn network_from_raw( .net .lines() .iter() - .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase())) - .map(|l| { - format!( - "line {} references unknown linecode `{}`", - l.name, l.linecode - ) - }) + .filter_map(|l| l.linecode.as_deref().map(|linecode| (l, linecode))) + .filter(|(_, linecode)| !known.contains(&linecode.to_ascii_lowercase())) + .map(|(l, linecode)| format!("line {} references unknown linecode `{}`", l.name, linecode)) .collect(); for message in missing { rd.diags.push( @@ -371,6 +379,10 @@ struct Reader<'a> { /// `linecode=` sets the line's phase count in the engine /// (Line.cpp FetchLineCode), exactly like `phases=`. linecode_nconds: BTreeMap, + /// LineGeometry name (lowercase) -> phase/conductor shape after `reduce`. + line_geometry_shapes: BTreeMap, + /// LineSpacing name (lowercase) -> stated phase/conductor shape. + line_spacing_shapes: BTreeMap, xycurves: BTreeMap, /// Transformer names (lowercase) a regcontrol targets, for the BMOPF /// regulator subtype classification pass. @@ -475,6 +487,36 @@ impl Reader<'_> { } } + fn remember_line_geometry(&mut self, obj: &RawObject) { + let props = Props::new(obj); + let phases = self.usize_or(&props, "nphases", "linegeometry", &obj.name, 3); + let conductors = self.usize_or(&props, "nconds", "linegeometry", &obj.name, 3); + let reduced = props.get("reduce").is_some_and(Value::to_bool); + let shape = if reduced { + LineShape::balanced(phases) + } else { + LineShape { + phases: phases.min(conductors), + conductors, + } + }; + self.line_geometry_shapes + .insert(obj.name.to_ascii_lowercase(), shape); + } + + fn remember_line_spacing(&mut self, obj: &RawObject) { + let props = Props::new(obj); + let phases = self.usize_or(&props, "nphases", "linespacing", &obj.name, 3); + let conductors = self.usize_or(&props, "nconds", "linespacing", &obj.name, 3); + self.line_spacing_shapes.insert( + obj.name.to_ascii_lowercase(), + LineShape { + phases: phases.min(conductors), + conductors, + }, + ); + } + fn f64_prop(&mut self, p: Option<&Value>) -> Option { p.and_then(|v| v.to_f64(Some(self.vars)).ok()) } @@ -851,47 +893,25 @@ impl Reader<'_> { // One block per object field; splitting it would scatter a list that // reads end to end. - #[expect(clippy::too_many_lines)] + #[allow(clippy::too_many_lines)] // one Line record replays shape and impedance assignments in order fn line(&mut self, obj: &RawObject) { let props = Props::new(obj); - // `linecode=` assigns the line's phase count from the code - // (Line.cpp FetchLineCode) exactly like `phases=`; properties - // apply in order, so the later of the two wins. Bus node lists - // materialize after the whole script parses (MakeBusList), so - // they always see the final count regardless of where the bus - // properties sit. - let explicit = self.usize_prop(props.get("phases")); - let from_code = props.get("linecode").and_then(|c| { - self.linecode_nconds - .get(&c.text.to_ascii_lowercase()) - .copied() - }); - let linecode_last = obj - .props - .iter() - .rev() - .find_map(|p| match p.name.as_deref() { - Some("phases") => Some(false), - Some("linecode") => Some(true), - _ => None, - }) - .unwrap_or(false); - let phases = match (explicit, from_code) { - (Some(_), Some(n)) if linecode_last => n, - (Some(p), _) => p, - (None, Some(n)) => n, - (None, None) => dd::line::PHASES, - }; + // `phases=`, `linecode=`, and `geometry=` each set the Line's + // shape when applied. Replay them in source order. This matters for + // both four-wire and one-wire SWER geometries: treating either as the + // three-phase Line default fabricates topology before a writer runs. + let shape = self.line_shape(obj); + let _ = props.get("phases"); + let phases = shape.phases; let spec1 = bus_spec(props.get("bus1"), ""); let spec2 = bus_spec(props.get("bus2"), ""); - // A line has no neutral conductor of its own: nconds == phases. - let map_from = self.terminals(&spec1, phases, phases, phases); - let map_to = self.terminals(&spec2, phases, phases, phases); + let map_from = self.terminals(&spec1, phases, shape.conductors, shape.conductors); + let map_to = self.terminals(&spec2, phases, shape.conductors, shape.conductors); let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool); if is_switch { let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS); - let i_max = Some(vec![amps; phases]); + let i_max = Some(vec![amps; shape.conductors]); let mut extras = extras_from_leftovers(&props); // OpenDSS replaces a switch line's impedance with fixed dummy // values; record anything written so nothing drops silently. @@ -931,26 +951,36 @@ impl Reader<'_> { // per line length unit, so the raw length preserves the Z·length // product. let mut malformed: Vec<(&'static str, String)> = Vec::new(); - let (linecode, length_factor, synthesized) = if let Some(code) = props.get("linecode") { - let lc_units_m = self - .linecode_units - .get(&code.text.to_ascii_lowercase()) - .copied() - .flatten(); - let factor = match (lc_units_m, line_units_m) { - (Some(_), Some(lf)) => lf, - (Some(lcf), None) => lcf, - (None, _) => 1.0, - }; - (code.text.clone(), factor, false) - } else { - let factor = line_units_m.unwrap_or(1.0); - let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name); - malformed = bad; - (code, factor, true) + let (linecode, length_factor, synthesized) = match Self::line_impedance_source(obj) { + LineImpedanceSource::LineCode(code) => { + let _ = props.get("linecode"); + let lc_units_m = self + .linecode_units + .get(&code.to_ascii_lowercase()) + .copied() + .flatten(); + let factor = match (lc_units_m, line_units_m) { + (Some(_), Some(lf)) => lf, + (Some(lcf), None) => lcf, + (None, _) => 1.0, + }; + (Some(code), factor, false) + } + LineImpedanceSource::Geometry => { + let factor = line_units_m.unwrap_or(1.0); + self.warn_unresolved_geometry(&obj.name); + (None, factor, false) + } + LineImpedanceSource::Inline => { + let factor = line_units_m.unwrap_or(1.0); + let (code, bad) = + self.synthesize_linecode(&props, shape.conductors, factor, &obj.name); + malformed = bad; + (Some(code), factor, true) + } }; - let (i_max, raw_emergamps) = self.line_rating(&props, phases, synthesized); + let (i_max, raw_emergamps) = self.line_rating(&props, shape.conductors, synthesized); let mut extras = extras_from_leftovers(&props); if let Some(u) = length_units { extras.insert("units".into(), u.into()); @@ -961,19 +991,99 @@ impl Reader<'_> { for (key, text) in malformed { extras.insert(key.to_string(), text.into()); } - self.net.lines_mut().push(DistLine { - name: obj.name.clone(), - bus_from: spec1.name, - bus_to: spec2.name, - terminal_map_from: map_from, - terminal_map_to: map_to, - linecode, - length: length * length_factor, - route: None, - i_max, - s_max: None, - extras, - }); + let length = length * length_factor; + let mut line = match linecode { + Some(linecode) => DistLine::new( + &obj.name, + &spec1.name, + &spec2.name, + map_from, + map_to, + linecode, + length, + ), + None => DistLine::new_unresolved( + &obj.name, + &spec1.name, + &spec2.name, + map_from, + map_to, + length, + ), + }; + line.i_max = i_max; + line.extras = extras; + self.net.lines_mut().push(line); + } + + /// Replay the OpenDSS properties that replace a Line's phase/conductor + /// shape. Unknown references leave the previous shape intact, matching an + /// engine assignment that cannot fetch the named object. + fn line_shape(&mut self, obj: &RawObject) -> LineShape { + let mut shape = LineShape::balanced(dd::line::PHASES); + for prop in &obj.props { + match prop.name.as_deref() { + Some("phases") => { + if let Some(n) = self.usize_prop(Some(&prop.value)) { + shape = LineShape::balanced(n); + } + } + Some("linecode") => { + if let Some(&n) = self + .linecode_nconds + .get(&prop.value.text.to_ascii_lowercase()) + { + shape = LineShape::balanced(n); + } + } + Some("geometry") => { + if let Some(&geometry) = self + .line_geometry_shapes + .get(&prop.value.text.to_ascii_lowercase()) + { + shape = geometry; + } + } + Some("spacing") => { + if let Some(&spacing) = self + .line_spacing_shapes + .get(&prop.value.text.to_ascii_lowercase()) + { + shape = spacing; + } + } + Some("wires" | "cncables" | "tscables") => { + let conductors = prop.value.to_string_list(Some(self.vars)).len(); + if conductors > 0 { + shape.conductors = conductors.min(MAX_COUNT); + shape.phases = shape.phases.min(shape.conductors); + } + } + _ => {} + } + } + shape + } + + /// The last impedance-defining assignment controls the Line. Geometry, + /// spacing, and conductor/cable lists require OpenDSS line-constant math; + /// they must not fall through to the unrelated factory R/X defaults. + fn line_impedance_source(obj: &RawObject) -> LineImpedanceSource { + let mut source = LineImpedanceSource::Inline; + for prop in &obj.props { + source = match prop.name.as_deref() { + Some("linecode") => LineImpedanceSource::LineCode(prop.value.text.clone()), + Some("geometry" | "spacing" | "wires" | "cncables" | "tscables") => { + LineImpedanceSource::Geometry + } + Some( + "rmatrix" | "xmatrix" | "cmatrix" | "r1" | "x1" | "r0" | "x0" | "c1" | "c0" + | "b1" | "b0", + ) => LineImpedanceSource::Inline, + _ => continue, + }; + } + source } /// The line's own `i_max`, and the raw `emergamps` token to keep in extras. @@ -1056,6 +1166,19 @@ impl Reader<'_> { (name, z.malformed) } + /// Record the deferred calculation without inventing an impedance + /// reference. The line carries `None`, which analysis and cross-format + /// writers handle as an explicit unresolved state. + fn warn_unresolved_geometry(&mut self, line_name: &str) { + self.warn( + &C::READ_DSS_IMPEDANCE_UNRESOLVED, + format!( + "line {line_name}: conductor geometry is retained, but its impedance is not \ + calculated; no linecode or electrical values were fabricated" + ), + ); + } + // ----- load ---------------------------------------------------------- /// Final (kWBase, kvarBase, PFNominal, LoadSpecType) after the last @@ -2470,6 +2593,27 @@ struct SeriesImpedance { malformed: Vec<(&'static str, String)>, } +#[derive(Clone, Copy)] +struct LineShape { + phases: usize, + conductors: usize, +} + +impl LineShape { + const fn balanced(n: usize) -> Self { + Self { + phases: n, + conductors: n, + } + } +} + +enum LineImpedanceSource { + LineCode(String), + Geometry, + Inline, +} + #[derive(Clone)] struct WindingRaw { bus: Option, @@ -2578,7 +2722,7 @@ mod tests { New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}" )); let line = net.lines().iter().find(|l| l.name == "l1").unwrap(); - let code = net.linecode(&line.linecode).unwrap(); + let code = net.linecode(line.linecode.as_deref().unwrap()).unwrap(); (code.r_series[0][0], line.length) } @@ -2616,7 +2760,7 @@ mod tests { New Line.l1 bus1=a.1 bus2=b.1 phases=1 length=0.5 units=km r1=0.5 x1=0.2 c1=3", ); let line = net.lines().iter().find(|l| l.name == "l1").unwrap(); - let code = net.linecode(&line.linecode).unwrap(); + let code = net.linecode(line.linecode.as_deref().unwrap()).unwrap(); assert!((line.length - 500.0).abs() < 1e-9); assert!((code.r_series[0][0] * line.length - 0.25).abs() < 1e-12); assert!((code.x_series[0][0] * line.length - 0.1).abs() < 1e-12); diff --git a/powerio-dist/src/dss/write.rs b/powerio-dist/src/dss/write.rs index 59fb8eb8f..ff527472a 100644 --- a/powerio-dist/src/dss/write.rs +++ b/powerio-dist/src/dss/write.rs @@ -1125,11 +1125,11 @@ impl DssWriter { ) else { continue; }; - let Some(code) = net - .linecodes() - .iter() - .find(|c| c.name.eq_ignore_ascii_case(&l.linecode)) - else { + let Some(code) = net.linecodes().iter().find(|c| { + l.linecode + .as_deref() + .is_some_and(|linecode| c.name.eq_ignore_ascii_case(linecode)) + }) else { continue; }; if code.n_conductors != l.terminal_map_from.len() @@ -1221,6 +1221,16 @@ impl DssWriter { fn lines(&mut self, net: &MulticonductorNetwork) { for l in net.lines() { self.check_name("line", &l.name); + let Some(linecode) = l.linecode.as_deref() else { + self.warn( + &C::EMIT_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED, + format!( + "line {} omitted from OpenDSS output: its impedance is unresolved", + l.name + ), + ); + continue; + }; // #307: with an agreed return conductor before the end and a // matching permuted linecode emitted, the node lists and the // matrices move together; otherwise the order warning stands. @@ -1233,7 +1243,7 @@ impl DssWriter { ) .filter(|_| { net.linecodes().iter().any(|c| { - c.name.eq_ignore_ascii_case(&l.linecode) + c.name.eq_ignore_ascii_case(linecode) && c.n_conductors == l.terminal_map_from.len() }) }); @@ -1242,7 +1252,7 @@ impl DssWriter { let perm = return_permutation(k, l.terminal_map_from.len()); map_from = permute_names(&l.terminal_map_from, &perm); map_to = permute_names(&l.terminal_map_to, &perm); - code_name = format!("{}_ret{k}", l.linecode); + code_name = format!("{linecode}_ret{k}"); self.warn( &C::EMIT_DSS_VALUE_SUBSTITUTED, format!( @@ -1268,7 +1278,7 @@ impl DssWriter { ); map_from = l.terminal_map_from.clone(); map_to = l.terminal_map_to.clone(); - code_name = l.linecode.clone(); + code_name = linecode.to_owned(); } let phases = l.terminal_map_from.len(); let mut s = format!( @@ -3254,7 +3264,7 @@ mod tests { bus_to: "a=b".into(), terminal_map_from: strings(&["1"]), terminal_map_to: strings(&["1"]), - linecode: "lc".into(), + linecode: Some("lc".into()), length: 1.0, route: None, i_max: None, @@ -3284,7 +3294,7 @@ mod tests { bus_to: "b2".into(), terminal_map_from: strings(&["1", "2", "3"]), terminal_map_to: strings(&["1", "2", "3"]), - linecode: "lc".into(), + linecode: Some("lc".into()), length: 1.0, route: None, i_max: Some(vec![400.0, 300.0, 200.0]), @@ -3318,7 +3328,7 @@ mod tests { bus_to: "b2".into(), terminal_map_from: strings(&["1"]), terminal_map_to: strings(&["1"]), - linecode: "lc".into(), + linecode: Some("lc".into()), length: 1.0, route: None, i_max: Some(vec![400.0]), diff --git a/powerio-dist/src/model.rs b/powerio-dist/src/model.rs index 95e4ee43b..fb5beeb46 100644 --- a/powerio-dist/src/model.rs +++ b/powerio-dist/src/model.rs @@ -165,7 +165,14 @@ pub struct DistLine { pub bus_to: String, pub terminal_map_from: Vec, pub terminal_map_to: Vec, - pub linecode: String, + /// Resolved impedance data for this line. + /// + /// `None` means that the source described impedance through a mechanism + /// PowerIO retained but did not calculate, such as OpenDSS conductor + /// geometry. Analysis and cross-format emission must refuse that state; + /// it must never be replaced by an electrical default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linecode: Option, /// Meters. A `null` reads as NaN: a BMOPF line without a length (#268). #[serde(with = "crate::nonfinite::nan_scalar")] #[cfg_attr( @@ -216,7 +223,30 @@ impl DistLine { bus_to: bus_to.into(), terminal_map_from, terminal_map_to, - linecode: linecode.into(), + linecode: Some(linecode.into()), + length, + route: None, + i_max: None, + s_max: None, + extras: Extras::new(), + } + } + + pub(crate) fn new_unresolved( + name: impl Into, + bus_from: impl Into, + bus_to: impl Into, + terminal_map_from: Vec, + terminal_map_to: Vec, + length: f64, + ) -> Self { + Self { + name: name.into(), + bus_from: bus_from.into(), + bus_to: bus_to.into(), + terminal_map_from, + terminal_map_to, + linecode: None, length, route: None, i_max: None, @@ -1163,12 +1193,9 @@ pub(crate) fn warn_defaulted_frequency( } } -/// Push a warning for every dangling or empty cross-reference. Bus and -/// linecode references are bare strings, a reader leaves an empty string -/// where the field is missing, and the graph projection synthesizes a -/// phantom bus for any unresolved id (the empty string included) — so a -/// typo or an absent field would otherwise parse cleanly into a -/// topologically wrong network. Comparison is ASCII case insensitive, +/// Push a finding for every dangling or empty cross-reference. Bus references +/// are bare strings; line impedance is explicitly absent when a source +/// construct has not been lowered. Comparison is ASCII case insensitive, /// matching [`MulticonductorNetwork::bus`] and [`MulticonductorNetwork::linecode`]. /// The unresolved bus and linecode references of a network, as one message /// per finding: the same walk the reader's warning pass runs, exposed so a @@ -1260,16 +1287,21 @@ pub(crate) fn warn_unresolved_references( } } for l in net.lines() { - if l.linecode.is_empty() { - warnings.push(format!( - "line {}: `linecode` reference is empty or missing", - l.name - )); - } else if !linecodes.contains(&l.linecode.to_ascii_lowercase()) { - warnings.push(format!( - "line {}: references undefined linecode `{}`", - l.name, l.linecode - )); + match l.linecode.as_deref() { + None | Some("") => diags.push( + &crate::diagnostics::codes::VALIDATE_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED, + format!( + "line {}: impedance is unresolved (`linecode` is absent)", + l.name + ), + ), + Some(linecode) if !linecodes.contains(&linecode.to_ascii_lowercase()) => { + warnings.push(format!( + "line {}: references undefined linecode `{linecode}`", + l.name + )); + } + Some(_) => {} } } for message in warnings { diff --git a/powerio-dist/src/pmd/read.rs b/powerio-dist/src/pmd/read.rs index 1ed0eb418..e83652320 100644 --- a/powerio-dist/src/pmd/read.rs +++ b/powerio-dist/src/pmd/read.rs @@ -601,7 +601,7 @@ impl Reader<'_> { bus_to: string(o.get("t_bus")), terminal_map_from: ints_as_strings(o.get("f_connections")), terminal_map_to: ints_as_strings(o.get("t_connections")), - linecode, + linecode: Some(linecode), length: o.get("length").map_or(f64::NAN, |v| restore("length", v)), route: None, i_max, diff --git a/powerio-dist/src/pmd/write.rs b/powerio-dist/src/pmd/write.rs index f6de34c54..f7a057b70 100644 --- a/powerio-dist/src/pmd/write.rs +++ b/powerio-dist/src/pmd/write.rs @@ -538,6 +538,16 @@ impl Writer { let mut lines = Map::new(); let linecodes_by_name = linecodes_by_name(net); for l in net.lines() { + let Some(linecode) = l.linecode.as_deref() else { + self.warn( + &C::EMIT_MULTICONDUCTOR_IMPEDANCE_UNRESOLVED, + format!( + "line {} omitted from PMD output: its impedance is unresolved", + l.name + ), + ); + continue; + }; let mut o = Map::new(); o.insert("f_bus".into(), json!(l.bus_from.to_lowercase())); o.insert("t_bus".into(), json!(l.bus_to.to_lowercase())); @@ -555,7 +565,7 @@ impl Writer { // its impedance, the dss2eng shape for rmatrix defined // lines: matrices on the line, no linecode key. let inline = l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true); - let lc = linecodes_by_name.get(l.linecode.to_ascii_lowercase().as_str()); + let lc = linecodes_by_name.get(linecode.to_ascii_lowercase().as_str()); match lc.copied() { Some(c) if inline => { insert_impedance_matrices(&mut o, c, net.base_frequency()); @@ -569,11 +579,10 @@ impl Writer { _ => { if inline { self.warn(&C::EMIT_PMD_VALUE_SUBSTITUTED, format!( - "{what}: linecode `{}` is missing; emitted the reference instead of inline impedance", - l.linecode + "{what}: linecode `{linecode}` is missing; emitted the reference instead of inline impedance" )); } - o.insert("linecode".into(), json!(l.linecode.to_lowercase())); + o.insert("linecode".into(), json!(linecode.to_lowercase())); } } Self::line_ratings(&mut o, l); @@ -1173,7 +1182,11 @@ fn inlined_codes(net: &MulticonductorNetwork) -> BTreeSet { let mut refs = net .lines() .iter() - .filter(|l| l.linecode.eq_ignore_ascii_case(&c.name)) + .filter(|l| { + l.linecode + .as_deref() + .is_some_and(|linecode| linecode.eq_ignore_ascii_case(&c.name)) + }) .peekable(); if refs.peek().is_some() && refs.all(|l| l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true)) diff --git a/powerio-dist/tests/bmopf.rs b/powerio-dist/tests/bmopf.rs index 205c6e5fd..65b64e7c6 100644 --- a/powerio-dist/tests/bmopf.rs +++ b/powerio-dist/tests/bmopf.rs @@ -2483,11 +2483,14 @@ fn synthesized_inline_linecode_never_shadows_a_declared_one() { // The declared code keeps its name and its impedance. assert_eq!(series("LC"), 9.0f64.to_bits()); let user = net.lines().iter().find(|l| l.name == "user").unwrap(); - assert_eq!(series(&user.linecode), 9.0f64.to_bits()); + assert_eq!(series(user.linecode.as_deref().unwrap()), 9.0f64.to_bits()); // The inline line takes a suffixed name and keeps its own impedance. let inline = net.lines().iter().find(|l| l.name == "lc").unwrap(); - assert_eq!(inline.linecode, "lc_"); - assert_eq!(series(&inline.linecode), 1.0f64.to_bits()); + assert_eq!(inline.linecode.as_deref(), Some("lc_")); + assert_eq!( + series(inline.linecode.as_deref().unwrap()), + 1.0f64.to_bits() + ); assert!( net.warnings .iter() @@ -2786,7 +2789,7 @@ fn inline_impedance_is_not_scaled_by_a_descriptive_length() { ); let net = parse_bmopf_str(&text).unwrap(); let line = &net.lines()[0]; - let code = net.linecode(&line.linecode).unwrap(); + let code = net.linecode(line.linecode.as_deref().unwrap()).unwrap(); assert_eq!(line.length.to_bits(), 1.0f64.to_bits()); assert_eq!( (code.r_series[0][0] * line.length).to_bits(), @@ -3209,7 +3212,7 @@ fn an_inline_line_rating_is_not_repeated_on_its_synthetic_linecode() { let code = net .linecodes() .iter() - .find(|c| c.name == line.linecode) + .find(|c| Some(c.name.as_str()) == line.linecode.as_deref()) .expect("synthetic linecode"); assert_eq!( diff --git a/powerio-dist/tests/dss_geometry_safety.rs b/powerio-dist/tests/dss_geometry_safety.rs index 6f8810793..0d9e900de 100644 --- a/powerio-dist/tests/dss_geometry_safety.rs +++ b/powerio-dist/tests/dss_geometry_safety.rs @@ -4,16 +4,22 @@ //! `CNData`, `TSData`) are currently deferred by the typed distribution model. //! Until typed geometry lowering exists, a geometry-backed line must not be //! normalized with OpenDSS `Line` factory impedance or a fabricated conductor -//! count. These tests are intentionally ignored while the normalization -//! contract is being changed; they document the expected safety invariant. +//! count. These tests lock down the fail-closed normalization contract. use std::fs; use powerio_core::Source; -use powerio_dist::parse; +use powerio_dist::diagnostics::DiagnosticSeverity; +use powerio_dist::{DistTargetFormat, parse, write_as, write_network}; -fn parse_text(name: &str, text: &str) -> powerio_core::PioModule { - let dir = std::env::temp_dir().join(format!("powerio-dss-geometry-{name}-{}", std::process::id())); +fn parse_text( + name: &str, + text: &str, +) -> powerio_core::PioModule { + let dir = std::env::temp_dir().join(format!( + "powerio-dss-geometry-{name}-{}", + std::process::id() + )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); let path = dir.join("master.dss"); @@ -22,11 +28,8 @@ fn parse_text(name: &str, text: &str) -> powerio_core::PioModule 1e-12) + .is_none_or(|r| (*r - 0.098_133_333_333_333_34).abs() > 1e-12) }), "geometry line must not inherit OpenDSS factory R1" ); + + // Retained OpenDSS can still be returned exactly. Any semantic write + // omits the unsafe line and reports an Error rather than inventing an + // impedance or allowing the target format to do so. + let same = write_as(&module, DistTargetFormat::Dss); + assert_eq!(same.text, input); + assert!(same.diagnostics.is_empty()); + for target in [ + DistTargetFormat::Dss, + DistTargetFormat::PmdJson, + DistTargetFormat::BmopfJson, + ] { + let converted = write_network(net, target); + assert!(converted.diagnostics.iter().any(|diagnostic| { + diagnostic.code() == "EMIT.MULTICONDUCTOR.IMPEDANCE_UNRESOLVED" + && diagnostic.severity() == DiagnosticSeverity::Error + })); + } +} + +#[test] +fn spacing_and_wire_lists_preserve_the_stated_conductor_shape() { + let module = parse_text( + "spacing", + r"clear +new circuit.t basekv=4.16 phases=3 bus1=sourcebus +new wiredata.phase rac=0.2 gmr=0.03 radius=0.4 runits=mi gmrunits=ft radunits=in +new wiredata.neutral rac=0.6 gmr=0.01 radius=0.2 runits=mi gmrunits=ft radunits=in +new linespacing.s nconds=4 nphases=3 units=ft x=(0 2 4 1) h=(30 30 30 25) +new line.l bus1=sourcebus bus2=b spacing=s wires=[phase phase phase neutral] length=1 units=m +", + ); + + let line = &module.value().lines()[0]; + assert_eq!(line.linecode, None); + assert_eq!(line.terminal_map_from.len(), 4); + assert_eq!(line.terminal_map_to.len(), 4); + assert!(module.diagnostics().iter().any(|diagnostic| { + diagnostic.code() == "READ.DSS.IMPEDANCE_UNRESOLVED" + && diagnostic.severity() == DiagnosticSeverity::Error + })); } #[test] -#[ignore = "geometry lowering contract is being hardened; see eigenergy/powerio#479"] fn single_conductor_geometry_does_not_become_three_conductors() { let module = parse_text( "swer", - r#"clear + r"clear new circuit.swer basekv=19.1 phases=1 bus1=sourcebus new wiredata.w rac=1.093 gmr=0.00296 radius=0.00318 runits=km gmrunits=m radunits=m new linegeometry.g nconds=1 nphases=1 reduce=no ~ cond=1 wire=w x=0 h=8.5 units=m new line.l bus1=sourcebus.1 bus2=b.1 geometry=g length=1 units=m -"#, +", ); let rendered = powerio_dist::diagnostics::render_diagnostics(module.diagnostics()); - assert!(rendered.iter().any(|d| d.to_ascii_lowercase().contains("geometry"))); + assert!( + rendered + .iter() + .any(|d| d.to_ascii_lowercase().contains("geometry")) + ); let net = module.value(); - assert!(net.lines().is_empty() || net.lines().iter().all(|l| { - l.terminal_map_from.len() == 1 && l.terminal_map_to.len() == 1 - })); + let line = &net.lines()[0]; + assert_eq!(line.linecode, None); + assert_eq!(line.terminal_map_from.len(), 1); + assert_eq!(line.terminal_map_to.len(), 1); } diff --git a/powerio-dist/tests/dss_reader.rs b/powerio-dist/tests/dss_reader.rs index bb3d45a97..f84cb8beb 100644 --- a/powerio-dist/tests/dss_reader.rs +++ b/powerio-dist/tests/dss_reader.rs @@ -146,7 +146,7 @@ fn ieee13_matches_the_engine_bus_map() { // ohm/m; length = 2000*0.3048 m. Product must match the engine. let line = net.lines().iter().find(|l| l.name == "650632").unwrap(); assert!((line.length - 2000.0 * 0.3048).abs() < 1e-9); - let code = net.linecode(&line.linecode).unwrap(); + let code = net.linecode(line.linecode.as_deref().unwrap()).unwrap(); let r11_total = code.r_series[0][0] * line.length; assert!((r11_total - 0.3465 * 2000.0 / 5280.0).abs() < 1e-9); @@ -214,7 +214,7 @@ fn defaults_materialize_with_provenance() { // the constructor default, materialized and recorded. let line = net.lines().iter().find(|l| l.name == "l_default").unwrap(); assert!((line.length - 1.0).abs() < 1e-12); - let code = net.linecode(&line.linecode).unwrap(); + let code = net.linecode(line.linecode.as_deref().unwrap()).unwrap(); // Sequence defaults: diag (2*0.058 + 0.1784)/3, off diag (0.1784-0.058)/3. assert!((code.r_series[0][0] - 0.098_133_333_333_333_33).abs() < 1e-12); assert!((code.r_series[0][1] - 0.040_133_333_333_333_33).abs() < 1e-12); diff --git a/powerio-dist/tests/pmd.rs b/powerio-dist/tests/pmd.rs index a6478fe52..7304da205 100644 --- a/powerio-dist/tests/pmd.rs +++ b/powerio-dist/tests/pmd.rs @@ -440,7 +440,7 @@ fn inline_line_impedance_round_trips() { }"#; let net = parse_pmd_str(text).unwrap(); let l = &net.lines()[0]; - assert_eq!(l.linecode, "ln1_z"); + assert_eq!(l.linecode.as_deref(), Some("ln1_z")); assert_eq!(l.extras.get("pmd_inline"), Some(&serde_json::json!(true))); // Inline ratings belong to the materialized linecode, not the line. assert!(l.i_max.is_none() && l.s_max.is_none()); @@ -619,10 +619,10 @@ fn inline_linecode_collision_with_document_linecode() { assert!(names.contains("foo_z") && names.contains("foo_z2")); let foo = net.lines().iter().find(|l| l.name == "foo").unwrap(); - assert_eq!(foo.linecode, "foo_z2"); + assert_eq!(foo.linecode.as_deref(), Some("foo_z2")); assert!((net.linecode("foo_z2").unwrap().r_series[0][0] - 0.111).abs() < 1e-15); let bar = net.lines().iter().find(|l| l.name == "bar").unwrap(); - assert_eq!(bar.linecode, "foo_z"); + assert_eq!(bar.linecode.as_deref(), Some("foo_z")); assert!((net.linecode("foo_z").unwrap().r_series[0][0] - 0.5).abs() < 1e-15); // The BMOPF projection keys linecodes by name; line foo must carry its