Skip to main content

powerio_dist/
convert.rs

1//! Distribution format dispatch.
2
3use crate::model::{DistSourceFormat, MulticonductorNetwork};
4
5/// Extra files an emitter generated beside the primary text payload.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct TextSidecar {
8    /// Relative file path the primary text refers to.
9    pub(crate) path: String,
10    /// File content.
11    pub(crate) text: String,
12}
13
14/// One format serializer's internal output before it commits to a destination.
15#[derive(Debug, Clone)]
16pub(crate) struct TextEmission {
17    pub(crate) text: String,
18    pub(crate) sidecars: Vec<TextSidecar>,
19    pub(crate) diagnostics: Vec<crate::diagnostics::Diagnostic>,
20    pub(crate) fidelity: powerio_core::Fidelity,
21}
22
23impl TextEmission {
24    pub(crate) fn new(
25        text: String,
26        sidecars: Vec<TextSidecar>,
27        diagnostics: crate::diagnostics::Diagnostics,
28    ) -> Self {
29        Self {
30            text,
31            sidecars,
32            diagnostics: diagnostics.into_records(),
33            fidelity: powerio_core::Fidelity::Canonical,
34        }
35    }
36
37    /// An emission that dropped nothing, e.g. a same format echo.
38    pub(crate) fn faithful(text: String) -> Self {
39        let mut emission = Self::new(text, Vec::new(), crate::diagnostics::Diagnostics::new());
40        emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
41        emission
42    }
43
44    /// Record one finding after the writer has run.
45    pub(crate) fn push(
46        &mut self,
47        info: &'static crate::diagnostics::DiagnosticInfo,
48        message: impl Into<String>,
49    ) {
50        self.diagnostics
51            .push(crate::diagnostics::Diagnostic::of(info, message));
52    }
53
54    #[cfg(test)]
55    pub(crate) fn render_diagnostics(&self) -> Vec<String> {
56        crate::diagnostics::render_diagnostics(&self.diagnostics)
57    }
58}
59
60/// A writable distribution format.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum DistTargetFormat {
64    Dss,
65    BmopfJson,
66    PmdJson,
67    /// Distribution CIM: one CIM100 RDF/XML document, or a directory of them
68    /// on input.
69    CimDist,
70    Gridlabd,
71    GridlabdArras,
72}
73
74/// Format specific policies for distribution network emission.
75#[derive(Clone, Debug, Default, PartialEq)]
76#[non_exhaustive]
77pub struct EmitOptions {
78    pub dss: crate::dss::DssEmitOptions,
79    pub bmopf: crate::bmopf::BmopfEmitOptions,
80}
81
82impl EmitOptions {
83    fn is_default_for(&self, format: DistTargetFormat) -> bool {
84        match format {
85            DistTargetFormat::Dss => self.dss == crate::dss::DssEmitOptions::default(),
86            DistTargetFormat::BmopfJson => self.bmopf == crate::bmopf::BmopfEmitOptions::default(),
87            DistTargetFormat::PmdJson
88            | DistTargetFormat::CimDist
89            | DistTargetFormat::Gridlabd
90            | DistTargetFormat::GridlabdArras => true,
91        }
92    }
93}
94
95/// Resolves common names and file extensions to a target format.
96pub fn parse_dist_target_format(name: &str) -> Option<DistTargetFormat> {
97    let key = canonical_key(name);
98    match key.as_str() {
99        "dss" | "opendss" => Some(DistTargetFormat::Dss),
100        "glm" | "gridlabd" | "gridlabdpnnl" => Some(DistTargetFormat::Gridlabd),
101        "gridlabdarras" => Some(DistTargetFormat::GridlabdArras),
102        "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
103        "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
104        "cimdist" => Some(DistTargetFormat::CimDist),
105        _ => None,
106    }
107}
108
109impl std::str::FromStr for DistTargetFormat {
110    type Err = crate::Error;
111
112    /// [`parse_dist_target_format`] as a `Result`, matching the transmission
113    /// hub's `TargetFormat: FromStr`.
114    fn from_str(s: &str) -> crate::Result<Self> {
115        parse_dist_target_format(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
116    }
117}
118
119impl DistTargetFormat {
120    /// The canonical format name, accepted
121    /// back by [`parse_dist_target_format`].
122    pub fn name(self) -> &'static str {
123        match self {
124            DistTargetFormat::Dss => "dss",
125            DistTargetFormat::Gridlabd => "gridlabd",
126            DistTargetFormat::GridlabdArras => "gridlabd-arras",
127            DistTargetFormat::PmdJson => "pmd-json",
128            DistTargetFormat::BmopfJson => "bmopf-json",
129            DistTargetFormat::CimDist => "cim-dist",
130        }
131    }
132}
133
134fn canonical_key(name: &str) -> String {
135    name.to_ascii_lowercase()
136        .chars()
137        .filter(|c| *c != '-' && *c != '_')
138        .collect()
139}
140
141/// Element tables that identify a distribution document beside its `bus`
142/// table.
143///
144/// `load`, `shunt`, and `switch` are shared with PowerModels, so on their own
145/// they cannot tell the two apart. They stay in the list all the same, because
146/// [`NOT_BMOPF_KEYS`] is what refuses a PowerModels document, and it refuses it
147/// whatever else the document holds. Dropping the shared names instead would
148/// refuse a real BMOPF feeder built only from them, which the reader parses
149/// and which this classifier used to accept.
150///
151/// These names do not separate BMOPF from PMD: the two share most of their
152/// element vocabulary. `data_model` does that, and it is checked first.
153const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
154    "capacitor",
155    // `control_profile` and `ibr` are typed dispatch tables of the reader,
156    // though schema 0.1.0 moved them under `extras`: a pre-0.1.0 document
157    // still declares them at the top level, and what the reader accepts the
158    // classifier must identify.
159    "control_profile",
160    "generator",
161    "ibr",
162    "line",
163    "linecode",
164    "load",
165    "meta",
166    "shunt",
167    "switch",
168    "terminal_conventions",
169    "transformer",
170    "voltage_source",
171];
172
173/// Top level keys no BMOPF document carries, and a PowerModels or MATPOWER
174/// derived document does. One of these refuses the BMOPF reading whatever
175/// else the document holds, so a name that a future BMOPF revision adds
176/// cannot make a PowerModels file classify.
177const NOT_BMOPF_KEYS: &[&str] = &[
178    "baseMVA",
179    "branch",
180    "dcline",
181    "gen",
182    "per_unit",
183    "source_type",
184    "source_version",
185    "storage",
186];
187
188/// The PMD marker. Neither BMOPF nor PowerModels carries this key, so it
189/// identifies the ENGINEERING and MATHEMATICAL models on its own; the PMD
190/// reader then rejects MATHEMATICAL with its own message.
191const PMD_MARKER: &str = "data_model";
192
193/// What the top level of a document holds, for classification only.
194///
195/// The probe reads the top level keys and skips every value, so it never
196/// materializes the document. The old classifier built a whole
197/// `serde_json::Value` and dropped it, which doubled the peak memory of a
198/// parse and did the tokenizing work twice: the chosen reader parses the
199/// same text again. A case file is attacker controlled input, so a reader
200/// sized allocation that serves no purpose is worth removing.
201// Five independent presence flags, which is what a marker probe is. An
202// enum or a builder, the shapes this lint steers toward, would model a
203// choice; these are not exclusive.
204#[allow(clippy::struct_excessive_bools)]
205#[derive(Default)]
206struct TopLevel {
207    /// The document is a JSON object.
208    is_object: bool,
209    pmd_marker: bool,
210    bus: bool,
211    /// A key from [`DISTRIBUTION_ELEMENT_TABLES`].
212    dist_table: bool,
213    /// A key from [`NOT_BMOPF_KEYS`].
214    not_bmopf: bool,
215}
216
217impl<'de> serde::Deserialize<'de> for TopLevel {
218    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
219        struct Probe;
220
221        impl<'de> serde::de::Visitor<'de> for Probe {
222            type Value = TopLevel;
223
224            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225                f.write_str("a JSON document")
226            }
227
228            fn visit_map<A: serde::de::MapAccess<'de>>(
229                self,
230                mut map: A,
231            ) -> Result<TopLevel, A::Error> {
232                let mut out = TopLevel {
233                    is_object: true,
234                    ..TopLevel::default()
235                };
236                // Keys arrive as borrowed or owned strings. Nothing is kept:
237                // each key sets a flag and each value is discarded, so the
238                // probe holds a constant amount of memory whatever the size
239                // of the document.
240                while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
241                    let key = key.as_ref();
242                    out.pmd_marker |= key == PMD_MARKER;
243                    out.bus |= key == "bus";
244                    out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
245                    out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
246                    map.next_value::<serde::de::IgnoredAny>()?;
247                }
248                Ok(out)
249            }
250
251            // A valid document that is not an object cannot be either
252            // format. Record that and let the caller report it, rather than
253            // failing as a parse error, which would send it to the BMOPF
254            // fallback.
255            fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
256                Ok(TopLevel::default())
257            }
258            fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
259                Ok(TopLevel::default())
260            }
261            fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
262                Ok(TopLevel::default())
263            }
264            fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
265                Ok(TopLevel::default())
266            }
267            fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
268                Ok(TopLevel::default())
269            }
270            fn visit_unit<E>(self) -> Result<TopLevel, E> {
271                Ok(TopLevel::default())
272            }
273            fn visit_none<E>(self) -> Result<TopLevel, E> {
274                Ok(TopLevel::default())
275            }
276            fn visit_seq<A: serde::de::SeqAccess<'de>>(
277                self,
278                mut seq: A,
279            ) -> Result<TopLevel, A::Error> {
280                while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
281                Ok(TopLevel::default())
282            }
283        }
284
285        deserializer.deserialize_any(Probe)
286    }
287}
288
289/// Distribution parser policy for a `.json` input.
290///
291/// Classification is by positive marker, never by "everything else is
292/// BMOPF": an unmarked document used to fall through to the BMOPF reader
293/// and parse into a bogus near-empty network.
294///
295/// - PMD carries `data_model`, which no other family here carries. The marker
296///   decides on its own: every real PMD ENGINEERING document also carries the
297///   element tables BMOPF uses, so a document holding both is the normal case
298///   and not a contradiction.
299/// - BMOPF carries a `bus` table beside at least one distribution element
300///   table, and no key that marks a PowerModels or MATPOWER derived document.
301/// - Anything else is refused with a message naming both rules.
302///
303/// Malformed JSON still routes to BMOPF so its reader reports the parse
304/// error, which names the byte offset. The probe never materializes the
305/// document, so classification costs one pass and constant memory.
306pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
307    // A leading byte order mark would fail the parse here and silently send
308    // a PMD document down the BMOPF fallback; classify without it.
309    let text = text.trim_start_matches('\u{feff}');
310    let unrecognized = |detail: &str| crate::Error::Json {
311        format: "distribution",
312        message: format!(
313            "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
314             carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
315             {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
316        ),
317    };
318
319    let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
320        return Ok(DistTargetFormat::BmopfJson);
321    };
322    if !top.is_object {
323        return Err(unrecognized("the top level is not an object"));
324    }
325
326    // `data_model` is authoritative. PMD ENGINEERING and BMOPF share most
327    // of their element table names (`line`, `linecode`, `transformer`, and
328    // the rest), so the marker separates them, not the table set. The PMD
329    // reader is what judges the marker's value, and it rejects the
330    // MATHEMATICAL model with its own message.
331    if top.pmd_marker {
332        return Ok(DistTargetFormat::PmdJson);
333    }
334    if top.bus && top.dist_table && !top.not_bmopf {
335        return Ok(DistTargetFormat::BmopfJson);
336    }
337    Err(if top.bus && top.not_bmopf {
338        unrecognized(
339            "it carries a `bus` table with PowerModels keys beside it, so it is a \
340             transmission document; read it through the transmission hub",
341        )
342    } else if top.bus {
343        unrecognized("its `bus` table has no distribution element table beside it")
344    } else {
345        unrecognized("it carries no marker of either format")
346    })
347}
348
349/// Parse one source into a compiled distribution module. The typed
350/// [`MulticonductorNetwork`] is the module's value; the reader's findings are
351/// the module's diagnostics; the source itself is retained on the module, so
352/// a same format emission echoes the input bytes exactly.
353///
354/// The format comes from the source's declared format when one was selected,
355/// the `.dss` or `.glm` extension otherwise, and for `.json` from
356/// [`classify_distribution_json`]. GridLAB-D defaults to the PNNL 5.3 profile
357/// (`gridlabd`, aliases `glm` and `gridlabd-pnnl`); `gridlabd-arras` selects
358/// the Arras 4.3 profile explicitly. See [`crate::glm::parse`] for explicit
359/// preprocessing inputs and the supported electrical profile.
360/// XML documents and directories use distribution CIM detection.
361///
362/// # Errors
363/// The operation failure carries the reader's findings up to the failure and
364/// retains the source for span interpretation.
365///
366pub fn parse(
367    source: powerio_core::Source,
368) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error> {
369    if source
370        .format()
371        .is_some_and(|f| is_sincal_multiconductor_token(f.as_str()))
372    {
373        return Err(sincal_feature_disabled(&source));
374    }
375    let selected = source
376        .format()
377        .and_then(|id| parse_dist_target_format(id.as_str()));
378    if matches!(
379        selected,
380        Some(DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras)
381    ) || (source.format().is_none()
382        && std::path::Path::new(source.name())
383            .extension()
384            .is_some_and(|ext| ext.eq_ignore_ascii_case("glm")))
385    {
386        let dialect = if selected == Some(DistTargetFormat::GridlabdArras) {
387            crate::glm::GlmDialect::Arras4_3
388        } else {
389            crate::glm::GlmDialect::Pnnl5_3
390        };
391        return crate::glm::parse(
392            source,
393            crate::glm::PreprocessOptions {
394                dialect,
395                ..Default::default()
396            },
397        );
398    }
399    let mut warnings = crate::collect::Diagnostics::new();
400    let mut dss_replay = None;
401    match parse_to_network(&source, &mut warnings, &mut dss_replay) {
402        Ok((network, format)) => {
403            // Record the detected format before the common constructor builds
404            // descriptors and the coarse `/value` source map.
405            let source = match powerio_core::FormatId::new(format.name()) {
406                Ok(format) => source.with_format(format),
407                Err(_) => source,
408            };
409            let mut module =
410                powerio_core::PioModule::parsed(network, source, warnings.into_records())?;
411            if let Some(replay) = dss_replay {
412                module.insert_extension(
413                    "org.powerio.opendss",
414                    serde_json::json!({
415                        "version": 1, "absolute_dependency": replay.absolute_dependency,
416                        "dependencies": replay.dependencies.iter().map(powerio_core::SourceId::as_str).collect::<Vec<_>>(),
417                    }),
418                )?;
419            }
420            Ok(module)
421        }
422        Err(error) => {
423            let core = powerio_core::Error::new(error.code(), error.to_string());
424            Err(core
425                .with_diagnostics(warnings.into_records())
426                .with_cause(error)
427                .with_source(source))
428        }
429    }
430}
431
432/// The format dispatch behind [`parse`]: the network and the format it was
433/// read as.
434fn parse_to_network(
435    source: &powerio_core::Source,
436    warnings: &mut crate::collect::Diagnostics,
437    dss_replay: &mut Option<crate::dss::read::ReplayContext>,
438) -> crate::Result<(MulticonductorNetwork, DistTargetFormat)> {
439    let declared = source
440        .format()
441        .map(powerio_core::FormatId::as_str)
442        .map(|name| {
443            parse_dist_target_format(name)
444                .ok_or_else(|| crate::Error::UnknownFormat(name.to_string()))
445        })
446        .transpose()?;
447    if source.is_directory() {
448        let cim = declared.map_or_else(
449            || crate::cim::source_looks_like_cim_dist(source),
450            |format| format == DistTargetFormat::CimDist,
451        );
452        if !cim {
453            return Err(crate::Error::UnknownFormat(format!(
454                "{} is a directory, and the only distribution format read from a directory is \
455                 a cim-dist document set; pass a case file",
456                source.name()
457            )));
458        }
459    }
460    let format = if let Some(format) = declared {
461        format
462    } else if source.is_directory() {
463        DistTargetFormat::CimDist
464    } else {
465        let ext = std::path::Path::new(source.name())
466            .extension()
467            .and_then(|e| e.to_str())
468            .unwrap_or_default()
469            .to_ascii_lowercase();
470        match ext.as_str() {
471            "dss" => DistTargetFormat::Dss,
472            "json" => {
473                let buffer = primary(source)?;
474                classify_distribution_json(source_text(&buffer)?)?
475            }
476            "xml" if crate::cim::looks_like_cim_dist(primary(source)?.content_bytes()) => {
477                DistTargetFormat::CimDist
478            }
479            other => return Err(crate::Error::UnknownFormat(other.to_string())),
480        }
481    };
482    let network = match format {
483        DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => Err(
484            crate::Error::UnknownFormat("GLM project requires the project parser".into()),
485        ),
486        DistTargetFormat::Dss => {
487            crate::dss::read::parse_dss_collecting(source, warnings, dss_replay)
488        }
489        DistTargetFormat::BmopfJson => {
490            let buffer = primary(source)?;
491            crate::bmopf::read::parse_bmopf_collecting(source_text(&buffer)?, warnings)
492        }
493        DistTargetFormat::PmdJson => {
494            let buffer = primary(source)?;
495            crate::pmd::read::parse_pmd_collecting(source_text(&buffer)?, warnings)
496        }
497        DistTargetFormat::CimDist => crate::cim::parse_cim_collecting(source, warnings),
498    }?;
499    Ok((network, format))
500}
501
502/// The retained primary buffer of a file or in-memory source.
503pub(crate) fn primary(source: &powerio_core::Source) -> crate::Result<powerio_core::SourceBuffer> {
504    source
505        .primary_buffer()
506        .map_err(|error| crate::Error::FormatRead {
507            format: "case text",
508            message: error.to_string(),
509        })
510}
511
512/// The buffer's decode slice as UTF-8 text: the retained bytes with a leading
513/// byte order mark skipped, so the mark survives for the echo and never
514/// reaches a reader.
515pub(crate) fn source_text(buffer: &powerio_core::SourceBuffer) -> crate::Result<&str> {
516    std::str::from_utf8(buffer.content_bytes()).map_err(|e| crate::Error::FormatRead {
517        format: "case text",
518        message: format!("not valid UTF-8: {e}"),
519    })
520}
521
522impl DistTargetFormat {
523    fn matches(self, source: Option<DistSourceFormat>) -> bool {
524        matches!(
525            (self, source),
526            (DistTargetFormat::Dss, Some(DistSourceFormat::Dss))
527                | (
528                    DistTargetFormat::BmopfJson,
529                    Some(DistSourceFormat::BmopfJson)
530                )
531                | (DistTargetFormat::PmdJson, Some(DistSourceFormat::PmdJson))
532                | (DistTargetFormat::CimDist, Some(DistSourceFormat::CimDist))
533        )
534    }
535}
536
537/// Whether `name` spells the explicit conductor-resolved SINCAL profile.
538pub(crate) fn is_sincal_multiconductor_token(name: &str) -> bool {
539    name.chars()
540        .filter(|c| !matches!(c, '-' | '_' | ' '))
541        .collect::<String>()
542        .eq_ignore_ascii_case("sincalmulticonductor")
543}
544
545/// The refusal a SINCAL source meets in a build without the `sincal` feature.
546fn sincal_feature_disabled(source: &powerio_core::Source) -> powerio_core::Error {
547    powerio_core::Error::new(
548        &crate::diagnostics::codes::REQUEST_SINCAL_MULTICONDUCTOR_FEATURE_DISABLED,
549        "SINCAL adapters live in powerio-tnd; use powerio with its `sincal` feature or powerio_tnd::formats::sincal",
550    )
551    .with_source(source.clone())
552}
553
554/// Prepare exact retained-source text or validated canonical output.
555pub(crate) fn emit_text_with_options(
556    module: &powerio_core::PioModule<MulticonductorNetwork>,
557    format: DistTargetFormat,
558    options: &EmitOptions,
559) -> Result<TextEmission, powerio_core::Error> {
560    let mut output = if matches!(
561        format,
562        DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras
563    ) {
564        crate::glm::emit_glm_text(module.value())?
565    } else if options.is_default_for(format)
566        && let Some(text) = echo_text(module, format)
567    {
568        TextEmission::faithful(text)
569    } else {
570        crate::readiness::require_resolved_geometry(module.value())?;
571        if format != DistTargetFormat::Dss {
572            crate::readiness::require_earth_referenced_sources(module.value())?;
573        }
574        emit_value_text_with_options(module.value(), format, options)?
575    };
576    powerio_core::attach_retention_details(
577        module.diagnostics(),
578        &mut output.diagnostics,
579        "EMIT.DIST.SINCAL_RETAINED_SOURCE_OMITTED",
580    )?;
581    Ok(output)
582}
583
584/// The retained source text when emitting `module` back to its source format:
585/// the echo that reproduces the input byte for byte. `None` sends the emission
586/// down the semantic path.
587fn echo_text(
588    module: &powerio_core::PioModule<MulticonductorNetwork>,
589    target: DistTargetFormat,
590) -> Option<String> {
591    let source = module.source()?;
592    let buffer = source.primary_buffer().ok()?;
593    // Both the retained source's declared format and the value's own must be
594    // the target: a deserialized module retains the PowerIO IR document, not
595    // the case its value came from.
596    let declared = source
597        .format()
598        .and_then(|format| parse_dist_target_format(format.as_str()))?;
599    if declared != target || !target.matches(*module.value().source_format()) {
600        return None;
601    }
602    let text = std::str::from_utf8(buffer.bytes()).ok()?;
603    Some(text.to_owned())
604}
605
606/// Serialize a typed network to `format` with no source echo.
607pub(crate) fn emit_value_text_with_options(
608    net: &MulticonductorNetwork,
609    format: DistTargetFormat,
610    options: &EmitOptions,
611) -> Result<TextEmission, powerio_core::Error> {
612    net.validate_component_locations()?;
613    let mut conv = match format {
614        DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => {
615            return crate::glm::emit_glm_text(net);
616        }
617        DistTargetFormat::Dss => {
618            crate::dss::validate_emission(net)?;
619            crate::dss::emit_dss_text_with_options(net, &options.dss)
620        }
621        DistTargetFormat::BmopfJson => {
622            crate::bmopf::emit_bmopf_json_text_with_options(net, options.bmopf)
623        }
624        DistTargetFormat::PmdJson => crate::pmd::emit_pmd_json_text(net),
625        DistTargetFormat::CimDist => crate::cim::emit_cim_dist_text(net),
626    };
627    if !net.component_locations().is_empty() {
628        conv.push(&crate::diagnostics::codes::EMIT_DIST_COMPONENT_COORDINATES_DROPPED,
629            format!("{} component point(s) dropped: {} cannot carry their attachment, partial-axis and datum semantics",
630                net.component_locations().len(), format.name()));
631    }
632    if *net.source_format() == Some(DistSourceFormat::Sincal) {
633        conv.push(
634            &crate::diagnostics::codes::EMIT_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_OMITTED,
635            "Native SINCAL source-only data are omitted from the typed electrical conversion.",
636        );
637    }
638    // No distribution format carries line routes; report the loss the
639    // way bus locations already do (`.pio.json` keeps them).
640    let routed = net
641        .lines()
642        .iter()
643        .filter(|line| line.route.is_some())
644        .count();
645    if routed > 0 {
646        conv.push(
647            &crate::diagnostics::codes::EMIT_MULTICONDUCTOR_ROUTE_DROPPED,
648            format!(
649                "{routed} line route(s) dropped: {} has no polyline field",
650                format.name()
651            ),
652        );
653    }
654    Ok(conv)
655}
656
657#[cfg(test)]
658pub(crate) fn emit_value_text(
659    net: &MulticonductorNetwork,
660    format: DistTargetFormat,
661) -> TextEmission {
662    emit_value_text_with_options(net, format, &EmitOptions::default())
663        .expect("test network is emittable")
664}
665
666/// Emit a parsed module to `format` through a destination.
667///
668/// The JSON and CIM targets commit a single artifact — a path destination names the
669/// exact file, a memory destination names the artifact. The dss target is a
670/// directory inventory: the destination names the output root, the case text
671/// commits as `case.dss`, and every companion file the emitter produced
672/// (OpenDSS `Buscoords` CSV) commits beside it, so nothing the case text
673/// refers to is missing from the output.
674/// GridLAB-D targets also write directory inventories. Unchanged modules
675/// replay every acquired buffer under its original relative path, preserving
676/// the entrypoint location and includes. Absolute includes cannot be relocated
677/// byte exactly and are refused. Fresh, edited, IR-restored and cross-profile
678/// modules write canonical `case.glm`; [`crate::glm::emit_canonical`] directly
679/// writes a single file from a network value.
680/// An unchanged multi-file DSS project retains the paths and bytes of every
681/// acquired file, dependencies and companions alike, beneath `source/`;
682/// `case.dss` redirects to its entry file. A remark
683/// (`EMIT.DSS.PROJECT_COMPANION_KEPT`) names the companions, the supplied
684/// files the deck never read.
685///
686/// # Errors
687/// Unsupported electrical features, nonportable retained projects, or the
688/// destination's own collision and staging failures.
689///
690/// # Panics
691/// Never on external input: the fixed artifact names are valid by
692/// construction.
693pub fn emit(
694    module: &powerio_core::PioModule<MulticonductorNetwork>,
695    format: DistTargetFormat,
696    destination: powerio_core::Destination,
697) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
698    emit_with_options(module, format, &EmitOptions::default(), destination)
699}
700
701/// [`emit`] with format specific policies.
702///
703/// # Errors
704/// The destination refused the output inventory.
705///
706/// # Panics
707/// Never on external input: the fixed artifact names are valid by
708/// construction.
709pub fn emit_with_options(
710    module: &powerio_core::PioModule<MulticonductorNetwork>,
711    format: DistTargetFormat,
712    options: &EmitOptions,
713    destination: powerio_core::Destination,
714) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
715    if matches!(
716        format,
717        DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras
718    ) && let Some(project) = crate::glm::retained_project(module, format)?
719    {
720        return destination.__commit_artifacts(
721            true,
722            powerio_core::Fidelity::ExactSameFormat,
723            project.artifacts,
724            project.diagnostics,
725        );
726    }
727    let conv = emit_text_with_options(module, format, options)?;
728    if format == DistTargetFormat::Dss
729        && options.is_default_for(format)
730        && let Some(project) = crate::dss::retained_project(module)?
731    {
732        let mut diagnostics = conv.diagnostics;
733        diagnostics.extend(project.diagnostics);
734        return destination.__commit_artifacts(true, conv.fidelity, project.artifacts, diagnostics);
735    }
736    match format {
737        DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => {
738            let artifact = powerio_core::MemoryArtifact::new(
739                powerio_core::ArtifactPath::new("case.glm")?,
740                conv.text.into_bytes(),
741            );
742            destination.__commit_artifacts(true, conv.fidelity, vec![artifact], conv.diagnostics)
743        }
744        DistTargetFormat::Dss => {
745            let mut artifacts = vec![powerio_core::MemoryArtifact::new(
746                powerio_core::ArtifactPath::new("case.dss")
747                    .expect("static name is a valid artifact path"),
748                conv.text.into_bytes(),
749            )];
750            for sidecar in conv.sidecars {
751                artifacts.push(powerio_core::MemoryArtifact::new(
752                    powerio_core::ArtifactPath::new(sidecar.path)?,
753                    sidecar.text.into_bytes(),
754                ));
755            }
756            destination.__commit_artifacts(true, conv.fidelity, artifacts, conv.diagnostics)
757        }
758        DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson | DistTargetFormat::CimDist => {
759            let artifact = powerio_core::MemoryArtifact::new(
760                powerio_core::ArtifactPath::new("case")
761                    .expect("static name is a valid artifact path"),
762                conv.text.into_bytes(),
763            );
764            destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
765        }
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
775        for doc in [
776            r#"{"data_model": "ENGINEERING"}"#,
777            r#"{"data_model": "MATHEMATICAL"}"#,
778            // The marker identifies the family whatever its value is; the
779            // PMD reader is what judges the value.
780            r#"{"data_model": 7}"#,
781            r#"{"data_model": null}"#,
782        ] {
783            assert_eq!(
784                classify_distribution_json(doc).unwrap(),
785                DistTargetFormat::PmdJson,
786                "{doc}"
787            );
788        }
789        for doc in [
790            r#"{"bus": {}, "voltage_source": {}}"#,
791            // A pre-0.1.0 feeder fragment: no `voltage_source`, but the
792            // reader accepts it, so the classifier must too.
793            r#"{"bus": {}, "line": {}, "linecode": {}}"#,
794            r#"{"bus": {}, "transformer": {}}"#,
795            r#"{"bus": {}, "capacitor": {}}"#,
796            r#"{"bus": {}, "generator": {}}"#,
797            // The pre-0.1.0 top-level spellings of the tables 0.1.0 moved
798            // under `extras`; the reader dispatches both.
799            r#"{"bus": {}, "ibr": {}}"#,
800            r#"{"bus": {}, "control_profile": {}}"#,
801        ] {
802            assert_eq!(
803                classify_distribution_json(doc).unwrap(),
804                DistTargetFormat::BmopfJson,
805                "{doc}"
806            );
807        }
808        // Malformed JSON still routes to BMOPF so its reader reports the
809        // parse error, which names the byte offset.
810        assert_eq!(
811            classify_distribution_json("{not json").unwrap(),
812            DistTargetFormat::BmopfJson
813        );
814        // A byte order mark must not push a PMD document down the BMOPF
815        // fallback.
816        assert_eq!(
817            classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
818            DistTargetFormat::PmdJson
819        );
820    }
821
822    /// A PowerModels document shares `bus`, `load`, `shunt`, `switch`, and
823    /// `name` with BMOPF, so none of those can be the discriminator. This
824    /// is the exact document family that used to parse into a bogus
825    /// near-empty `MulticonductorNetwork`.
826    #[test]
827    fn a_powermodels_document_never_classifies_as_bmopf() {
828        // The real key set a powerio PowerModels write produces.
829        let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
830            "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
831            "source_type": "matpower", "source_version": "2", "storage": {},
832            "switch": {}}"#;
833        assert!(classify_distribution_json(powermodels).is_err());
834
835        // Each PowerModels marker refuses the BMOPF reading on its own, even
836        // beside a real BMOPF table. A future BMOPF revision that adds a
837        // colliding table name therefore cannot make this document classify.
838        for marker in NOT_BMOPF_KEYS {
839            let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
840            assert!(
841                classify_distribution_json(&doc).is_err(),
842                "`{marker}` must refuse the BMOPF reading: {doc}"
843            );
844        }
845    }
846
847    /// The two rules pull in opposite directions and this classifier has
848    /// swung both ways: dropping the shared table names refuses real BMOPF
849    /// feeders, and keeping them without the veto reads PowerModels as BMOPF.
850    /// Pin both ends together so neither correction can undo the other.
851    #[test]
852    fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
853        // A BMOPF feeder built only from names PowerModels also uses. No
854        // veto key is present, so the distribution reading stands.
855        for doc in [
856            r#"{"bus": {}, "load": {}}"#,
857            r#"{"bus": {}, "shunt": {}}"#,
858            r#"{"bus": {}, "switch": {}}"#,
859            r#"{"bus": {}, "meta": {"frequency": 60}}"#,
860        ] {
861            assert_eq!(
862                classify_distribution_json(doc).unwrap(),
863                DistTargetFormat::BmopfJson,
864                "{doc}"
865            );
866        }
867        // The same shared names beside one veto key stay transmission.
868        for doc in [
869            r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
870            r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
871            r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
872        ] {
873            assert!(classify_distribution_json(doc).is_err(), "{doc}");
874        }
875    }
876
877    /// PMD ENGINEERING and BMOPF share most element table names, so a real
878    /// PMD document carries `data_model` beside `line` and `linecode`. The
879    /// marker must win, or every PMD file would be read as BMOPF.
880    #[test]
881    fn the_pmd_marker_wins_over_shared_element_tables() {
882        let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
883        assert_eq!(
884            classify_distribution_json(both).unwrap(),
885            DistTargetFormat::PmdJson
886        );
887    }
888
889    #[test]
890    fn unclassifiable_documents_are_refused_with_a_reason() {
891        for (doc, needle) in [
892            (
893                r#"{"bus": {"data_model": {}}}"#,
894                "no distribution element table",
895            ),
896            (r#"{"name": "data_model"}"#, "no marker of either format"),
897            ("{}", "no marker of either format"),
898            ("[]", "not an object"),
899            ("null", "not an object"),
900            ("3", "not an object"),
901            (r#""a string""#, "not an object"),
902            ("true", "not an object"),
903        ] {
904            let err = classify_distribution_json(doc).unwrap_err().to_string();
905            assert!(err.contains(needle), "{doc}: got {err}");
906        }
907    }
908
909    /// The probe reads top level keys and skips values, so neither the size
910    /// of a value nor the number of keys can make it allocate the document.
911    /// A deeply nested value hits serde_json's own recursion limit, which
912    /// surfaces as the malformed-JSON route rather than a stack overflow.
913    #[test]
914    fn the_probe_is_bounded_on_adversarial_shapes() {
915        // A huge value under an ignored key: skipped, not materialized.
916        let big = format!(
917            r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
918            "0,".repeat(200_000) + "0"
919        );
920        assert_eq!(
921            classify_distribution_json(&big).unwrap(),
922            DistTargetFormat::BmopfJson
923        );
924
925        // Many distinct top level keys: one flag per key, nothing stored.
926        let mut keys = String::new();
927        for i in 0..50_000 {
928            use std::fmt::Write as _;
929            let _ = write!(keys, "\"k{i}\":0,");
930        }
931        let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
932        assert_eq!(
933            classify_distribution_json(&many).unwrap(),
934            DistTargetFormat::BmopfJson
935        );
936
937        // Deep nesting under an ignored key: `IgnoredAny` skips a value
938        // without recursion, so depth costs no stack. The old classifier
939        // built a `serde_json::Value`, whose recursive descent refuses past
940        // 128 levels, so a legitimate document nested deeper than that used
941        // to take the malformed route.
942        let deep = format!(
943            r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
944            "[".repeat(20_000),
945            "]".repeat(20_000)
946        );
947        assert_eq!(
948            classify_distribution_json(&deep).unwrap(),
949            DistTargetFormat::BmopfJson
950        );
951
952        // A duplicate marker key is still one marker.
953        assert_eq!(
954            classify_distribution_json(
955                r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
956            )
957            .unwrap(),
958            DistTargetFormat::PmdJson
959        );
960    }
961
962    /// The probe skips a value without recursion, so it accepts a document
963    /// nested far deeper than the reader will take. The reader must then
964    /// refuse that document with an error, never with a crash: the
965    /// classifier is what decides which reader sees untrusted input.
966    #[test]
967    fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
968        for depth in [200usize, 20_000, 500_000] {
969            let doc = format!(
970                "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
971                "[".repeat(depth),
972                "]".repeat(depth)
973            );
974            let format = classify_distribution_json(&doc).expect("markers are present");
975            assert_eq!(format, DistTargetFormat::BmopfJson);
976            let err = crate::testkit::parse_str(&doc, format.name())
977                .expect_err("the reader refuses past its recursion limit");
978            assert!(
979                err.to_string().contains("recursion limit"),
980                "depth {depth}: {err}"
981            );
982        }
983    }
984
985    /// JSON keys are case sensitive and both formats are machine written,
986    /// so a near miss must not classify. It would pick a reader that then
987    /// fails on every table.
988    #[test]
989    fn marker_matching_is_case_sensitive() {
990        for doc in [
991            r#"{"Data_Model": "ENGINEERING"}"#,
992            r#"{"DATA_MODEL": "ENGINEERING"}"#,
993            r#"{"Bus": {}, "Linecode": {}}"#,
994        ] {
995            assert!(classify_distribution_json(doc).is_err(), "{doc}");
996        }
997    }
998
999    #[test]
1000    fn byte_order_mark_is_retained_and_echoed() {
1001        let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
1002        let net = crate::testkit::parse_dss_str(dss);
1003        assert_eq!(net.name().as_deref(), Some("c"));
1004        assert!(
1005            !net.warnings.iter().any(|w| w.contains("byte order mark")),
1006            "retaining the mark is not a loss: {:?}",
1007            net.warnings
1008        );
1009        // The echo returns the input bytes exactly, mark included; the
1010        // decode slice the reader saw was mark free.
1011        assert_eq!(net.emit(DistTargetFormat::Dss).text, dss);
1012        assert!(
1013            net.source
1014                .as_ref()
1015                .is_some_and(|s| !s.starts_with('\u{feff}'))
1016        );
1017    }
1018
1019    #[test]
1020    fn memory_and_file_sources_parse_the_same_fixture_alike() {
1021        let path = concat!(
1022            env!("CARGO_MANIFEST_DIR"),
1023            "/../tests/data/dist/micro/onephase_zip_load.dss"
1024        );
1025        let bytes = std::fs::read(path).unwrap();
1026        let from_bytes =
1027            crate::testkit::parse_str(std::str::from_utf8(&bytes).unwrap(), "dss").unwrap();
1028        let from_file = crate::testkit::parse_file(path, None).unwrap();
1029        assert_eq!(from_bytes.buses().len(), from_file.buses().len());
1030        assert_eq!(from_bytes.loads().len(), from_file.loads().len());
1031        assert_eq!(from_bytes.source, from_file.source);
1032    }
1033
1034    #[test]
1035    fn non_utf8_bytes_are_refused_and_name_the_encoding() {
1036        // 0xE9 is CP1252 é, the classic single byte a Windows editor leaves.
1037        let bytes: &[u8] = b"clear\nnew circuit.caf\xE9 basekv=12.47 bus1=src\n";
1038        let source = powerio_core::Source::from_memory("<memory>", bytes.to_vec())
1039            .unwrap()
1040            .with_format(powerio_core::FormatId::new("dss").unwrap());
1041        let err = parse(source).unwrap_err();
1042        assert!(err.to_string().contains("UTF-8"), "{err}");
1043    }
1044
1045    #[test]
1046    fn parse_rejects_unclassifiable_json() {
1047        // A PowerModels document used to fall through to the BMOPF reader
1048        // and parse into a bogus near-empty network.
1049        let dir = tempfile::tempdir().unwrap();
1050        let path = dir.path().join("case.json");
1051        std::fs::write(
1052            &path,
1053            r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
1054        )
1055        .unwrap();
1056        let err = crate::testkit::parse_file(&path, None).unwrap_err();
1057        assert!(
1058            err.to_string()
1059                .contains("not a recognized distribution document"),
1060            "{err}"
1061        );
1062        // An explicit format still overrides the classifier.
1063        assert!(crate::testkit::parse_file(&path, Some("bmopf-json")).is_ok());
1064    }
1065
1066    #[test]
1067    fn unknown_format_names_fail_before_any_work() {
1068        assert!(matches!(
1069            crate::testkit::parse_str("", "matpower"),
1070            Err(crate::Error::UnknownFormat(_))
1071        ));
1072        assert!(matches!(
1073            "matpower".parse::<DistTargetFormat>(),
1074            Err(crate::Error::UnknownFormat(_))
1075        ));
1076        assert!(matches!(
1077            crate::testkit::parse_file("missing.dss", Some("matpower")),
1078            Err(crate::Error::UnknownFormat(_))
1079        ));
1080    }
1081
1082    #[test]
1083    fn parse_diagnostics_remain_on_the_module_when_it_is_emitted() {
1084        let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
1085                   new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
1086        let source = powerio_core::Source::from_memory("<memory>", dss.as_bytes().to_vec())
1087            .unwrap()
1088            .with_format(powerio_core::FormatId::new("dss").unwrap());
1089        let module = parse(source).unwrap();
1090        let lines = crate::diagnostics::render_diagnostics(module.diagnostics());
1091        assert!(
1092            lines.iter().any(|w| w.contains("furlong")),
1093            "parse diagnostics stay on PioModule: {lines:?}"
1094        );
1095        emit(
1096            &module,
1097            DistTargetFormat::BmopfJson,
1098            powerio_core::Destination::memory("case.json").unwrap(),
1099        )
1100        .unwrap();
1101    }
1102
1103    #[test]
1104    fn canonical_format_bypasses_same_format_dss_echo() {
1105        let src = "Clear\n\
1106                   New Circuit.c basekv=12.47 bus1=sourcebus\n\
1107                   New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
1108        let net = crate::testkit::parse_dss_str(src);
1109        assert_eq!(net.emit(DistTargetFormat::Dss).text, src);
1110
1111        let canonical = net.emit_value(DistTargetFormat::Dss);
1112        assert_ne!(canonical.text, src);
1113        assert!(
1114            canonical
1115                .text
1116                .lines()
1117                .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
1118            "{}",
1119            canonical.text
1120        );
1121    }
1122}