Skip to main content

powerio_tx/format/
mod.rs

1//! Parsing and emission for supported case formats, all meeting at [`BalancedNetwork`].
2//!
3//! Each format module owns its parser and/or serializer: MATPOWER `.m`,
4//! PowerModels JSON, PSS/E `.raw`, PowerWorld `.aux`, egret `ModelData` JSON,
5//! pandapower JSON, PyPSA CSV folders, PSLF `.epc`, PSS/E RAWX 35, PowSybl
6//! XIIDM and JIIDM 1.0 through 1.17, CIM CGMES 2.4.15 and 3.0, GO Challenge 3 JSON,
7//! Surge JSON, and
8//! DeepMind OPFData JSON. PowerWorld `.pwb` cases, OPFData JSON, and the
9//! IEEE Common Data Format are input
10//! only. GO Challenge 3 defines a calculation rather than a bare network, so
11//! its implementation is private to the `powerio` facade's typed parser.
12//! PowerWorld `.pwd` displays read through the top-level `powerio::parse` as a
13//! `powerio.GeoLayer`. Case input and
14//! output formats meet here, so adding a format that supports emission is one module plus
15//! one hub registration.
16//! [`parse`] compiles a retained source into a typed module, detecting the
17//! format from the source name and content. [`emit`] emits a parsed
18//! module through a destination and echoes the retained source for a same
19//! format target.
20//! Non-finite numeric values, such as MATPOWER `Inf`/`NaN` angle limits, are
21//! emitted as JSON `null`.
22//!
23//! # Fidelity behavior
24//!
25//! Emission has two fidelity tiers:
26//!
27//! - **Same format emission of an unchanged parsed module returns the original
28//!   bytes.** The module retains its source, so [`emit`] back to the same
29//!   format returns every field, comment, and numeric token.
30//! - **Cross-format keeps maximal fidelity with itemized loss.** Whatever the
31//!   target format cannot represent is reported by
32//!   [`EmitResult::diagnostics`](powerio_core::EmitResult::diagnostics), never
33//!   dropped silently. During parsing, parsers itemize what
34//!   they ignore on the module's diagnostics.
35
36use std::collections::{BTreeSet, HashMap};
37use std::fmt;
38use std::str::FromStr;
39
40use serde_json::{Map, Value};
41
42use powerio_core::PioModule;
43
44use crate::diagnostics::{Diagnostic, DiagnosticInfo, Diagnostics, EmitFamily, codes};
45use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
46use crate::network::{
47    BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat, Transformer3W,
48};
49use crate::{Error, Result};
50use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
51
52mod cgmes;
53mod decode;
54mod egret;
55pub(crate) mod goc3;
56mod ieee_cdf;
57mod json_extension;
58mod matpower;
59mod opfdata;
60mod pandapower;
61mod powermodels;
62pub mod powerworld;
63mod powsybl_psse;
64mod pslf;
65pub(crate) mod psse;
66mod pypsa;
67mod ravens;
68mod rawx;
69pub mod routing;
70mod surge;
71
72mod ucte;
73mod union_find;
74mod xiidm;
75mod xml;
76
77pub use powerworld::{PwdDisplay, PwdSubstation};
78
79#[doc(hidden)]
80pub use egret::{
81    egret_declares_time_series as __egret_declares_time_series,
82    emit_time_series as __emit_egret_time_series,
83    parse_egret_time_series as __parse_egret_time_series,
84};
85#[doc(hidden)]
86pub use opfdata::{OpfDataSolution, parse_opfdata_json as __parse_opfdata_json};
87#[doc(hidden)]
88pub use pypsa::{
89    PypsaAxis, PypsaCsvSequence, parse_pypsa_csv_time_series as __parse_pypsa_csv_time_series,
90    pypsa_axis as __pypsa_axis,
91};
92
93pub use cgmes::CgmesVersion;
94pub(crate) use egret::write_egret_json;
95pub(crate) use pandapower::write_pandapower_json;
96pub(crate) use powermodels::write_powermodels_json;
97pub(crate) use powerworld::write_powerworld;
98pub(crate) use pslf::write_pslf;
99pub(crate) use psse::write_psse_rev;
100pub(crate) use ravens::write_ravens_json;
101pub(crate) use rawx::write_rawx;
102pub(crate) use surge::write_surge_json;
103pub(crate) use xiidm::{write_jiidm, write_xiidm};
104
105/// A target case format. See [`emit`].
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum TargetFormat {
109    /// PowerModels.jl network data JSON.
110    PowerModelsJson,
111    /// egret `ModelData` JSON.
112    EgretJson,
113    /// PSS/E `.raw` at the given revision. `rev` selects the record layout the
114    /// serializer emits (33, 34, or 35); 33 is the historical default. The parser
115    /// takes the revision from the file header, so this only affects emission.
116    Psse { rev: u32 },
117    /// PSS/E Extensible Power Flow Data File, revision 35 JSON.
118    PsseRawx,
119    /// PowerWorld auxiliary `.aux`.
120    PowerWorld,
121    /// pandapower `pandapowerNet` JSON.
122    PandapowerJson,
123    /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source).
124    Matpower,
125    /// GE PSLF `.epc` (round-trip; byte-exact when the case kept its source).
126    Pslf,
127    /// DOE GO Challenge 3 JSON problem or solution data. The `powerio` facade
128    /// owns its typed problem and solution handling; direct `powerio-tx`
129    /// parsing refuses this calculation format.
130    Goc3Json,
131    /// Surge native JSON network document.
132    SurgeJson,
133    /// One JSON document from a DeepMind OPFData release. Read only except for
134    /// an exact emission back to the retained source format.
135    DeepMindOpfDataJson,
136    /// PowSybl XIIDM XML, version 1.17.
137    Xiidm,
138    /// PowSybl JIIDM JSON, version 1.17.
139    Jiidm,
140    /// IEC CIM Common Grid Model Exchange Specification profile set.
141    Cgmes,
142    /// ENTSO-E UCTE-DEF `.uct`; fresh output uses revision 2007.05.01.
143    Ucte,
144    /// MG-RAVENS JSON, the LANL CIM-derived interchange schema
145    /// (<https://github.com/lanl-ansi/MG-RAVENS>), balanced subset.
146    RavensJson,
147}
148
149impl TargetFormat {
150    /// Conventional file extension for this format (no leading dot).
151    #[must_use]
152    pub fn extension(self) -> &'static str {
153        match self {
154            TargetFormat::PowerModelsJson
155            | TargetFormat::EgretJson
156            | TargetFormat::PandapowerJson
157            | TargetFormat::Goc3Json
158            | TargetFormat::SurgeJson
159            | TargetFormat::RavensJson
160            | TargetFormat::DeepMindOpfDataJson => "json",
161            TargetFormat::Psse { .. } => "raw",
162            TargetFormat::PsseRawx => "rawx",
163            TargetFormat::PowerWorld => "aux",
164            TargetFormat::Matpower => "m",
165            TargetFormat::Pslf => "epc",
166            TargetFormat::Xiidm => "xiidm",
167            TargetFormat::Jiidm => "jiidm",
168            TargetFormat::Cgmes => "xml",
169            TargetFormat::Ucte => "uct",
170        }
171    }
172
173    /// Human-readable format name for diagnostics.
174    #[must_use]
175    pub fn label(self) -> &'static str {
176        match self {
177            TargetFormat::PowerModelsJson => "PowerModels JSON",
178            TargetFormat::EgretJson => "egret JSON",
179            TargetFormat::Psse { .. } => "PSS/E .raw",
180            TargetFormat::PsseRawx => "PSS/E RAWX 35",
181            TargetFormat::PowerWorld => "PowerWorld .aux",
182            TargetFormat::PandapowerJson => "pandapower JSON",
183            TargetFormat::Matpower => "MATPOWER .m",
184            TargetFormat::Pslf => "PSLF .epc",
185            TargetFormat::Goc3Json => "GO Challenge 3 JSON",
186            TargetFormat::SurgeJson => "Surge JSON",
187            TargetFormat::RavensJson => "MG-RAVENS JSON",
188            TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
189            TargetFormat::Xiidm => "XIIDM 1.17 XML",
190            TargetFormat::Jiidm => "JIIDM 1.17 JSON",
191            TargetFormat::Cgmes => "CGMES 3.0 profile set",
192            TargetFormat::Ucte => "UCTE-DEF .uct",
193        }
194    }
195
196    /// Canonical API token for this format.
197    #[must_use]
198    pub fn token(self) -> &'static str {
199        match self {
200            TargetFormat::PowerModelsJson => "powermodels-json",
201            TargetFormat::EgretJson => "egret-json",
202            TargetFormat::Psse { rev: 34 } => "psse34",
203            TargetFormat::Psse { rev: 35 } => "psse35",
204            TargetFormat::Psse { .. } => "psse",
205            TargetFormat::PsseRawx => "psse-rawx",
206            TargetFormat::PowerWorld => "powerworld",
207            TargetFormat::PandapowerJson => "pandapower-json",
208            TargetFormat::Matpower => "matpower",
209            TargetFormat::Pslf => "pslf",
210            TargetFormat::Goc3Json => "goc3-json",
211            TargetFormat::SurgeJson => "surge-json",
212            TargetFormat::RavensJson => "ravens-json",
213            TargetFormat::DeepMindOpfDataJson => "opfdata-json",
214            TargetFormat::Xiidm => "xiidm",
215            TargetFormat::Jiidm => "jiidm",
216            TargetFormat::Cgmes => "cgmes",
217            TargetFormat::Ucte => "ucte",
218        }
219    }
220}
221
222impl fmt::Display for TargetFormat {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.write_str(self.token())
225    }
226}
227
228impl FromStr for TargetFormat {
229    type Err = Error;
230
231    fn from_str(name: &str) -> Result<Self> {
232        parse_target_format(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
233    }
234}
235
236/// Map a format name (with the common aliases) to a [`TargetFormat`], or `None`
237/// if unrecognized. Accepts `matpower`/`m`, `powermodels-json`/`powermodels`/`pm`,
238/// `egret-json`/`egret`, `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`,
239/// `powerworld`/`aux`, `pslf`/`epc`, `goc3-json`/`goc3`, and
240/// `surge-json`/`surge`, `opfdata-json`/`opfdata`/`gridopt`, `xiidm`, `jiidm`,
241/// `cgmes`, and `ucte`/`uct`.
242/// Case-insensitive. The one place the bindings (Python, C ABI) share, so a new
243/// format means one new arm here, not three. CGMES emits a profile directory;
244/// PyPSA CSV folders, GridFM datasets, PowerWorld `.pwb`, and IEEE CDF cases
245/// are routed by [`crate::format::routing`].
246///
247/// [`SourceFormat`]'s reported token is [`SourceFormat::name`], which resolves
248/// here directly, so a module can emit to another module's source format
249/// token for every supported case format. Compact spellings such as
250/// `powermodelsjson` are accepted as format name aliases.
251#[must_use]
252pub fn parse_target_format(name: &str) -> Option<TargetFormat> {
253    // `iidm` and `rawx` are accepted input spellings. Output metadata and
254    // requests use the unambiguous grid exchange format names.
255    if name.eq_ignore_ascii_case("iidm") || name.eq_ignore_ascii_case("rawx") {
256        return None;
257    }
258    Some(match routing::parse_transmission_format(name)? {
259        TransmissionFormat::Matpower => TargetFormat::Matpower,
260        TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
261        TransmissionFormat::EgretJson => TargetFormat::EgretJson,
262        TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
263        TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
264        TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
265        TransmissionFormat::PsseRawx => TargetFormat::PsseRawx,
266        TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
267        TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
268        TransmissionFormat::Pslf => TargetFormat::Pslf,
269        TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
270        TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
271        TransmissionFormat::RavensJson => TargetFormat::RavensJson,
272        TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
273        TransmissionFormat::Xiidm => TargetFormat::Xiidm,
274        TransmissionFormat::Jiidm => TargetFormat::Jiidm,
275        TransmissionFormat::Cgmes => TargetFormat::Cgmes,
276        TransmissionFormat::Ucte => TargetFormat::Ucte,
277        TransmissionFormat::PypsaCsv
278        | TransmissionFormat::SincalBalanced
279        | TransmissionFormat::Pwb
280        | TransmissionFormat::Gridfm
281        | TransmissionFormat::IeeeCdf => {
282            return None;
283        }
284    })
285}
286
287/// Parse a declared input format. `iidm` and `rawx` are accepted here only and
288/// normalized to the canonical `xiidm` and `psse-rawx` tokens on the resulting
289/// module.
290fn parse_source_target_format(name: &str) -> Option<TargetFormat> {
291    match routing::parse_transmission_format(name) {
292        Some(TransmissionFormat::Xiidm) => Some(TargetFormat::Xiidm),
293        Some(TransmissionFormat::PsseRawx) => Some(TargetFormat::PsseRawx),
294        _ => parse_target_format(name),
295    }
296}
297
298fn display_file_guidance() -> Error {
299    Error::UnknownFormat(
300        "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
301         `powerio::parse` reads it as powerio.GeoLayer"
302            .into(),
303    )
304}
305
306/// Render a file extension for a user-facing message: `` extension `xyz` ``
307/// when present, `no extension` otherwise.
308fn describe_extension(extension: Option<&str>) -> String {
309    match extension {
310        Some(ext) => format!("extension `{ext}`"),
311        None => "no extension".to_owned(),
312    }
313}
314
315/// Whether `name` selects a display or layer document rather than a case
316/// format. The facade routes such a name to `powerio.GeoLayer`.
317fn is_display_format_name(name: &str) -> bool {
318    matches!(
319        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
320        "pwd" | "powerworldpwd" | "powerworlddisplay" | "geojson" | "geo" | "geolayer"
321    )
322}
323
324/// Whether a format name means a PyPSA CSV folder. PyPSA folders are directory
325/// inputs, not text targets, so they have no [`TargetFormat`] arm; this is the
326/// companion alias matcher to [`parse_target_format`] and the one place the
327/// PyPSA aliases live.
328pub fn is_pypsa_csv_name(name: &str) -> bool {
329    matches!(
330        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
331        "pypsacsv" | "pypsa"
332    )
333}
334
335/// Whether a source format name means PSLF EPC.
336fn is_pslf_name(name: &str) -> bool {
337    matches!(
338        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
339        "pslf" | "epc" | "pslfepc"
340    )
341}
342
343/// Whether a source format name means the IEEE Common Data Format.
344fn is_ieee_cdf_name(name: &str) -> bool {
345    routing::parse_transmission_format(name) == Some(TransmissionFormat::IeeeCdf)
346}
347
348/// Parse the case file at `path`, choosing the parser from `from` (the
349/// [`parse_target_format`] names plus `pypsa-csv`/`pypsa`, `pwb`, `pslf`,
350/// and `epc`) or, when `None`, from the path: a directory containing
351/// `network.csv` parses as a PyPSA CSV folder (any other directory is refused
352/// as a directory with [`Error::UnknownFormat`], before extension inference),
353/// and a file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`/`epc`),
354/// case insensitively (issue #97: `.RAW` is as common as `.raw` in the wild);
355/// a `.txt` or `.cdf` file whose first card is an IEEE CDF title card reads
356/// as `ieee-cdf`. A
357/// `.json` file is classified by top level shape markers: pandapower
358/// (`"_class": "pandapowerNet"`), egret (`elements` and `system`), GO Challenge
359/// 3 (`network` plus `time_series_input`/`reliability`, refused here with
360/// guidance to the typed facade parser), Surge JSON
361/// (`format: "surge-json"`), OPFData (`grid`, `solution`, and `metadata`), and
362/// PowerModels JSON (`baseMVA`, `branch`, `gen`, or `gencost`). JSON matching
363/// distribution markers, ambiguous markers, or no known markers returns
364/// [`Error::UnknownFormat`].
365/// Declare a format on the source to force a parser. PowerWorld `.pwb` is a
366/// binary input only format; PSLF `.epc` is text and supports emission. Returns
367/// the typed module: the network value, the parser's findings, and the retained
368/// source.
369///
370/// The balanced network parser used by the top level facade. The CLI and
371/// language bindings call that facade so calculation formats such as GO
372/// Challenge 3 keep their typed values.
373///
374/// # Errors
375/// A `Request` failure when the format cannot be determined or is refused, an
376/// `Io` failure when acquisition fails, and the parser's own failure on
377/// malformed input. Findings collected before a failure ride the returned
378/// error.
379///
380pub fn parse(
381    source: powerio_core::Source,
382) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
383    parse_with_json_class(source, None)
384}
385
386/// [`parse`], given a JSON classification the caller already computed on the
387/// same bytes. The `powerio` facade routes a source by its own call to
388/// [`routing::classify_json_text`] before it ever reaches this crate; when
389/// that routing lands on the balanced hub, passing the result here skips the
390/// second classification [`parse`] would otherwise run over the identical
391/// text. `None` reproduces [`parse`] exactly, classifying inline only if and
392/// when [`parse_to_network`] needs to.
393///
394/// Not part of this crate's public reading surface — the facade is the one
395/// caller with a classification already in hand — so this stays out of the
396/// rendered docs.
397///
398/// # Errors
399/// Same as [`parse`].
400#[doc(hidden)]
401pub fn parse_with_json_class(
402    source: powerio_core::Source,
403    json_class: Option<routing::JsonClass>,
404) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
405    if sincal_source_declared(&source) {
406        return Err(sincal_feature_disabled(&source));
407    }
408    // Classify JSON once so the facade can pass the answer through without a
409    // second scan over the same bytes.
410    let json_class = json_class.or_else(|| {
411        let buffer = source.primary_buffer().ok()?;
412        let text = std::str::from_utf8(buffer.content_bytes()).ok()?;
413        Some(routing::classify_json_text(text))
414    });
415    let is_rawx = source
416        .format()
417        .and_then(|format| parse_target_format(format.as_str()))
418        == Some(TargetFormat::PsseRawx)
419        || std::path::Path::new(source.name())
420            .extension()
421            .and_then(|extension| extension.to_str())
422            .is_some_and(|extension| extension.eq_ignore_ascii_case("rawx"))
423        || matches!(
424            json_class,
425            Some(routing::JsonClass::Case(routing::Detection::Known(
426                routing::SourceFormat::Transmission(TransmissionFormat::PsseRawx)
427            )))
428        );
429    let is_xiidm = source.format().is_some_and(|format| {
430        routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Xiidm)
431    }) || std::path::Path::new(source.name())
432        .extension()
433        .and_then(|extension| extension.to_str())
434        .is_some_and(|extension| extension.eq_ignore_ascii_case("xiidm"))
435        || (source.format().is_none()
436            && source
437                .primary_buffer()
438                .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes())));
439    let is_jiidm = source.format().is_some_and(|format| {
440        routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Jiidm)
441    }) || std::path::Path::new(source.name())
442        .extension()
443        .and_then(|extension| extension.to_str())
444        .is_some_and(|extension| extension.eq_ignore_ascii_case("jiidm"))
445        || matches!(
446            json_class,
447            Some(routing::JsonClass::Case(routing::Detection::Known(
448                routing::SourceFormat::Transmission(TransmissionFormat::Jiidm)
449            )))
450        );
451    let is_cgmes = source.format().is_some_and(|format| {
452        routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Cgmes)
453    }) || cgmes::looks_like_profile_set(&source);
454    let mut warnings = Diagnostics::new();
455    match parse_to_network(&source, &mut warnings, json_class) {
456        Ok(mut network) => {
457            network.assign_missing_component_ids();
458            // Record the detected format on the retained source before the
459            // common constructor builds descriptors and the coarse root
460            // source map. RAWX aliases normalize to the one public token.
461            let source = if is_rawx {
462                source.with_format(
463                    powerio_core::FormatId::new("psse-rawx")
464                        .expect("the canonical RAWX token is valid"),
465                )
466            } else if is_jiidm {
467                source.with_format(
468                    powerio_core::FormatId::new("jiidm")
469                        .expect("the canonical JIIDM token is valid"),
470                )
471            } else if is_xiidm {
472                source.with_format(
473                    powerio_core::FormatId::new("xiidm")
474                        .expect("the canonical XIIDM token is valid"),
475                )
476            } else if is_cgmes {
477                source.with_format(
478                    powerio_core::FormatId::new("cgmes")
479                        .expect("the canonical CGMES token is valid"),
480                )
481            } else if source.format().is_some() {
482                source
483            } else {
484                match powerio_core::FormatId::new(network.source_format().name()) {
485                    Ok(format) => source.with_format(format),
486                    Err(_) => source,
487                }
488            };
489            PioModule::parsed(network, source, warnings.into_records())
490        }
491        Err(error) => {
492            // A reader that failed on a located record leaves that record's
493            // byte range on the collector; the failure carries it as a span.
494            let mut core = powerio_core::Error::new(error.code(), error.to_string());
495            if let Some(span) = warnings.record_span() {
496                core = core.with_span(span);
497            }
498            Err(core
499                .with_diagnostics(warnings.into_records())
500                .with_cause(error)
501                .with_source(source))
502        }
503    }
504}
505
506/// The format dispatch behind [`parse`]: name and content detection, then the
507/// one reader map. `json_class` is a classification the caller already
508/// computed on this source's own text ([`parse_with_json_class`]); when it is
509/// `None`, this classifies inline at the point a `.json` source needs it,
510/// exactly as [`parse`] always has.
511#[allow(clippy::too_many_lines)]
512fn parse_to_network(
513    source: &powerio_core::Source,
514    warnings: &mut Diagnostics,
515    json_class: Option<routing::JsonClass>,
516) -> Result<BalancedNetwork> {
517    let from = source.format().map(powerio_core::FormatId::as_str);
518    let path = std::path::Path::new(source.name());
519    // The file stem is the name hint for formats that don't carry their own
520    // name. An angle bracketed source name is the conventional non-file
521    // spelling an anonymous in-memory caller uses and carries no hint; a name
522    // with an extension contributes its stem, and any other name is the hint
523    // itself.
524    let stem = if source.name().starts_with('<') {
525        None
526    } else if path.extension().is_some() {
527        path.file_stem().and_then(|stem| stem.to_str())
528    } else {
529        Some(source.name())
530    };
531    // PyPSA CSV folders are directories, not files; dispatch them before any
532    // extension logic. `from` accepts the pypsa aliases, and a bare directory
533    // source with a `network.csv` auto-detects.
534    if source.is_directory() {
535        let marker = powerio_core::ArtifactPath::new("network.csv")
536            .expect("static name is a valid artifact path");
537        if from.is_some_and(is_pypsa_csv_name) || (from.is_none() && source.buffer(&marker).is_ok())
538        {
539            return pypsa::read_pypsa_csv_source(source, warnings);
540        }
541        if from.is_some_and(|format| {
542            routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
543        }) || (from.is_none() && cgmes::looks_like_profile_set(source))
544        {
545            return cgmes::parse_source(source, warnings);
546        }
547        // Any other directory has no reader; refuse it as a directory before
548        // the extension logic reads ".07" off a name like `pglib-opf-23.07`.
549        return Err(Error::UnknownFormat(format!(
550            "{} is a directory, and the only directory case format is a PyPSA CSV \
551             folder (one holding a network.csv); pass a case file",
552            path.display()
553        )));
554    }
555    if from.is_some_and(is_pypsa_csv_name) {
556        return Err(Error::UnknownFormat(
557            "a PyPSA CSV case is a directory holding a network.csv; open the folder as the source"
558                .into(),
559        ));
560    }
561    if from.is_some_and(|format| {
562        routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
563    }) || (from.is_none() && cgmes::looks_like_profile_set(source))
564    {
565        return cgmes::parse_source(source, warnings);
566    }
567    // PowerWorld `.pwb` is binary and read only; dispatch it before the text
568    // read. `from` accepts "pwb" for files with a different extension.
569    let ext = path
570        .extension()
571        .and_then(|e| e.to_str())
572        .map(str::to_ascii_lowercase);
573    let looks_like_xiidm = source
574        .primary_buffer()
575        .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes()));
576    if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
577        || (from.is_none() && ext.as_deref() == Some("pwb"))
578    {
579        // Binary input: the exact bytes go to the reader, byte order mark
580        // handling included, since the mark is a text concept.
581        let buffer = primary(source)?;
582        return powerworld::parse_pwb_collecting(buffer.bytes(), stem, warnings);
583    }
584    if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
585        let buffer = primary(source)?;
586        let network = pslf::parse_pslf_source(source_text(&buffer)?, stem, warnings)?;
587        reject_empty_case(&network, "PSLF .epc")?;
588        return Ok(network);
589    }
590    // An IEEE CDF case has no fixed extension: the public archives use
591    // `.txt` and some tools `.cdf`, so those two are inferred from the
592    // title card layout and any other name needs the declared format.
593    if from.is_some_and(is_ieee_cdf_name)
594        || (from.is_none()
595            && matches!(ext.as_deref(), Some("txt" | "cdf"))
596            && source
597                .primary_buffer()
598                .is_ok_and(|buffer| ieee_cdf::looks_like_ieee_cdf(buffer.content_bytes())))
599    {
600        let buffer = primary(source)?;
601        let text = source_text(&buffer)?;
602        // Record spans refer to the whole retained buffer, so the decoded
603        // text's offset past a byte order mark is part of every span.
604        let origin = ieee_cdf::TextOrigin::new(
605            buffer.id().clone(),
606            (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
607        );
608        let network = ieee_cdf::parse_ieee_cdf_source(text, stem, Some(origin), warnings)?;
609        reject_empty_case(&network, ieee_cdf::FMT)?;
610        return Ok(network);
611    }
612    if from
613        .and_then(parse_source_target_format)
614        .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
615        && matches!(ext.as_deref(), Some("pt" | "gz"))
616    {
617        return Err(Error::UnknownFormat(
618            "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
619                .into(),
620        ));
621    }
622    // Settle the format before touching the file: an unmapped or binary
623    // extension must surface as UnknownFormat, not as the UTF-8 read error
624    // the text formats' loader would hit first. `.pwd` gets its own arm
625    // because the display sibling ships next to every case file in the wild
626    // and carries no case data.
627    if from.is_none() && ext.as_deref() == Some("pwd") {
628        return Err(display_file_guidance());
629    }
630    let fmt_hint = match from {
631        Some(f) => {
632            if is_display_format_name(f) {
633                return Err(display_file_guidance());
634            }
635            Some(parse_source_target_format(f).ok_or_else(|| unknown_source_format(f))?)
636        }
637        None => {
638            // Everything but `.json` (sniffed below) resolves without the text.
639            match ext.as_deref() {
640                Some("m") => Some(TargetFormat::Matpower),
641                Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
642                Some("rawx") => Some(TargetFormat::PsseRawx),
643                Some("aux") => Some(TargetFormat::PowerWorld),
644                Some("xiidm") => Some(TargetFormat::Xiidm),
645                Some("jiidm") => Some(TargetFormat::Jiidm),
646                Some("xml") if looks_like_xiidm => Some(TargetFormat::Xiidm),
647                Some("xml" | "zip") => Some(TargetFormat::Cgmes),
648                Some("uct") => Some(TargetFormat::Ucte),
649                Some("json") => None,
650                Some("dss") => return Err(unknown_source_format("dss")),
651                other => {
652                    // A nameless or oddly named source can still carry a JSON
653                    // document (in-memory text has no extension to state);
654                    // sniff it like a `.json` before refusing. The primary
655                    // buffer is already retained, so peeking is free.
656                    let jsonish = source.primary_buffer().is_ok_and(|buffer| {
657                        source_text(&buffer)
658                            .is_ok_and(|text| text.trim_start().starts_with(['{', '[']))
659                    });
660                    if jsonish {
661                        None
662                    } else {
663                        return Err(Error::UnknownFormat(format!(
664                            "cannot infer from source name with {}; \
665                             declare a source format",
666                            describe_extension(other)
667                        )));
668                    }
669                }
670            }
671        }
672    };
673    // The parser decodes a byte order mark free slice of the one retained
674    // buffer; the module keeps the exact original bytes for same format
675    // writing. Sniffing a `.json` borrows the same slice.
676    let buffer = primary(source)?;
677    if fmt_hint == Some(TargetFormat::Xiidm) {
678        let network = xiidm::parse_xiidm_bytes(buffer.content_bytes(), warnings)?;
679        reject_empty_case(&network, TargetFormat::Xiidm.label())?;
680        return Ok(network);
681    }
682    let text = source_text(&buffer)?;
683    // Readers that locate records mark them as byte ranges of `text`; the
684    // retained buffer starts with the byte order mark `text` omits.
685    warnings.locate_in(
686        buffer.id().clone(),
687        (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
688    );
689    let fmt = match fmt_hint {
690        Some(fmt) => fmt,
691        // A caller ahead of this (the `powerio` facade's own routing) may
692        // already have classified this exact text; trust that answer instead
693        // of running the same classification a second time. `unwrap_or_else`
694        // only classifies here when nothing did yet, so a caller with no
695        // hint (every direct `parse` caller) behaves exactly as before.
696        None => {
697            json_target_from_class(json_class.unwrap_or_else(|| routing::classify_json_text(text)))?
698        }
699    };
700    if fmt == TargetFormat::Cgmes {
701        // A `.xml` profile the CGMES sniff missed arrives here rather than
702        // through `cgmes::parse_source`, and keeps the same byte limit.
703        cgmes::check_text_budget(source, text)?;
704    }
705    read_source(text, fmt, stem, warnings)
706}
707
708/// The primary buffer of a file or memory source.
709fn primary(source: &powerio_core::Source) -> Result<powerio_core::SourceBuffer> {
710    source.primary_buffer().map_err(|error| Error::FormatRead {
711        format: "source",
712        message: error.to_string(),
713    })
714}
715
716/// The text a reader decodes: the buffer's byte order mark free slice,
717/// validated as UTF-8.
718fn source_text(buffer: &powerio_core::SourceBuffer) -> Result<&str> {
719    std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
720        format: "case text",
721        message: format!("not valid UTF-8: {e}"),
722    })
723}
724
725/// Read decoded `text` as `fmt`, using `name_hint` (e.g. the file stem) when
726/// the format carries no name of its own. The single format to reader map:
727/// every parse route funnels through it, so every format is dispatched the
728/// same way. Readers borrow the text; the module retains the source bytes,
729/// and `warnings` is located in that buffer for readers that attach record
730/// spans to their findings.
731fn read_source(
732    text: &str,
733    fmt: TargetFormat,
734    name_hint: Option<&str>,
735    warnings: &mut Diagnostics,
736) -> Result<BalancedNetwork> {
737    let net = match fmt {
738        TargetFormat::Matpower => matpower::parse_matpower_source(text, name_hint, warnings),
739        TargetFormat::PowerModelsJson => {
740            powermodels::parse_powermodels_json_source(text, name_hint, warnings)
741        }
742        TargetFormat::Psse { .. } => psse::parse_psse_source(text, name_hint, warnings),
743        TargetFormat::PsseRawx => rawx::parse_rawx_source(text, name_hint, warnings),
744        TargetFormat::PowerWorld => {
745            powerworld::map::parse_powerworld_source(text, name_hint, warnings)
746        }
747        TargetFormat::EgretJson => egret::parse_egret_source(text, name_hint),
748        TargetFormat::PandapowerJson => {
749            pandapower::parse_pandapower_source(text, name_hint, warnings)
750        }
751        // PSLF read normally enters through the `is_pslf_name`/`.epc` fast
752        // path in the dispatch; this arm keeps the funnel total.
753        TargetFormat::Pslf => pslf::parse_pslf_source(text, name_hint, warnings),
754        TargetFormat::Goc3Json => {
755            return Err(Error::UnknownFormat(
756                "goc3-json defines a GO Challenge 3 calculation; use powerio::parse to obtain AcScucInstance or AcScucSolution"
757                    .into(),
758            ));
759        }
760        TargetFormat::SurgeJson => surge::parse_surge_source(text, name_hint, warnings),
761        TargetFormat::RavensJson => ravens::parse_ravens_source(text, name_hint, warnings),
762        TargetFormat::DeepMindOpfDataJson => {
763            opfdata::parse_opfdata_source(text, name_hint, warnings)
764        }
765        TargetFormat::Xiidm => xiidm::parse_xiidm_source(text, warnings),
766        TargetFormat::Jiidm => xiidm::parse_jiidm_source(text, warnings),
767        TargetFormat::Cgmes => {
768            cgmes::parse_text(name_hint.unwrap_or("profile.xml"), text, warnings)
769        }
770        TargetFormat::Ucte => ucte::parse_ucte_source(text, name_hint, warnings),
771    }?;
772    reject_empty_case(&net, fmt.label())?;
773    Ok(net)
774}
775
776/// Geographic metadata for a reader that harvested longitude/latitude
777/// coordinates: `Some` once any bus carries a location, so a case without
778/// coordinates serializes exactly as before. The space is stamped geographic
779/// only when every point fits longitude/latitude bounds; a source that
780/// violates its format's own convention (projected meters in a pandapower
781/// `geo` column) reads as unknown instead of claiming WGS84.
782pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
783    let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
784    located.peek()?;
785    let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
786    Some(crate::geo::GeoMeta {
787        space: if in_bounds {
788            crate::geo::CoordinateSpace::Geographic { crs: None }
789        } else {
790            crate::geo::CoordinateSpace::Unknown
791        },
792        kind: None,
793    })
794}
795
796/// A source id from an f64: an in range value truncates the way the readers
797/// always have; a negative, non-finite, or over-ceiling value is refused with
798/// a message naming `column`, instead of letting the `as usize` cast saturate.
799/// The ceiling is [`crate::network::BusId::MAX`] (`i64::MAX`, the C ABI id
800/// bound), applied to every id column so a non-bus id gets the same policy.
801#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
802pub(crate) fn id_from_f64(
803    value: f64,
804    column: impl std::fmt::Display,
805) -> std::result::Result<usize, String> {
806    // Strict `<`: `i64::MAX as f64` rounds up to 2^63, so `<=` would admit
807    // values the cast saturates past `BusId::MAX`.
808    if value >= 0.0 && value < i64::MAX as f64 {
809        Ok(value as usize)
810    } else {
811        // Debug keeps the shortest float form ("1e300", never 301 digits).
812        Err(format!(
813            "`{column}` value {value:?} is outside the id range 0..2^63"
814        ))
815    }
816}
817
818/// The nominal voltage a writer states for a bus whose source case declares
819/// none.
820///
821/// XIIDM and CGMES state impedances in ohms and voltages in kV, so every
822/// voltage level carries a positive nominal voltage; a case that works only in
823/// per unit (a MATPOWER bus row with `BASE_KV = 0`) states none. One
824/// substituted kilovolt keeps every derived ohm, siemens, and kV value finite
825/// and returns the same per-unit model, because a reader divides by the same
826/// nominal voltage the writer multiplied by.
827pub(crate) const SUBSTITUTE_NOMINAL_KV: f64 = 1.0;
828
829/// Whether a bus states a nominal voltage an absolute unit format can carry.
830pub(crate) fn states_nominal_voltage(bus: &crate::network::Bus) -> bool {
831    bus.base_kv.is_finite() && bus.base_kv > 0.0
832}
833
834/// The ids of the buses that state no nominal voltage, as the diagnostic
835/// spells them: at most five, then an ellipsis.
836pub(crate) fn unstated_nominal_voltage(network: &BalancedNetwork) -> Option<(usize, String)> {
837    let ids = network
838        .buses()
839        .iter()
840        .filter(|bus| !states_nominal_voltage(bus))
841        .map(|bus| bus.id.to_string())
842        .collect::<Vec<_>>();
843    if ids.is_empty() {
844        return None;
845    }
846    let shown = ids.iter().take(5).map(String::as_str).collect::<Vec<_>>();
847    let ellipsis = if ids.len() > shown.len() { ", ..." } else { "" };
848    Some((ids.len(), format!("{}{ellipsis}", shown.join(", "))))
849}
850
851/// How many reactive limits state no bound.
852///
853/// A format that states no reactive limits at all (a PyPSA generator CSV) is
854/// read as unbounded, and an absolute unit format states each limit as a
855/// number.
856pub(crate) fn unbounded_reactive_limits(network: &BalancedNetwork) -> usize {
857    let generators = network
858        .generators()
859        .iter()
860        .flat_map(|generator| [generator.qmin, generator.qmax]);
861    let storage = network
862        .storage()
863        .iter()
864        .flat_map(|storage| [storage.qmin, storage.qmax]);
865    let hvdc = network
866        .hvdc()
867        .iter()
868        .flat_map(|line| [line.qminf, line.qmaxf, line.qmint, line.qmaxt]);
869    generators
870        .chain(storage)
871        .chain(hvdc)
872        .filter(|value| value.is_infinite())
873        .count()
874}
875
876/// Replace an unbounded reactive limit with the largest finite double, which
877/// is how PowSybl states an unbounded limit
878/// (`MinMaxReactiveLimitsImpl(-Double.MAX_VALUE, Double.MAX_VALUE)`).
879pub(crate) fn substitute_unbounded_reactive_limits(network: &mut BalancedNetwork) {
880    fn bound(value: &mut f64) {
881        if value.is_infinite() {
882            *value = value.signum() * f64::MAX;
883        }
884    }
885    for generator in network.generators_mut() {
886        bound(&mut generator.qmin);
887        bound(&mut generator.qmax);
888    }
889    for storage in network.storage_mut() {
890        bound(&mut storage.qmin);
891        bound(&mut storage.qmax);
892    }
893    for line in network.hvdc_mut() {
894        bound(&mut line.qminf);
895        bound(&mut line.qmaxf);
896        bound(&mut line.qmint);
897        bound(&mut line.qmaxt);
898    }
899}
900
901/// Reject a case with neither an AC calculation view nor physical DC equipment.
902/// XIIDM 1.17 permits a network containing only DC nodes and equipment, while
903/// the other balanced formats still need at least one bus.
904pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
905    let detailed = net.detailed_connectivity();
906    let has_dc_equipment = detailed.as_ref().is_some_and(|detailed| {
907        !detailed.dc_nodes.is_empty()
908            || !detailed.dc_grounds.is_empty()
909            || !detailed.dc_lines.is_empty()
910            || !detailed.dc_switches.is_empty()
911    });
912    let has_empty_xiidm_voltage_level = matches!(
913        net.source_format(),
914        SourceFormat::Xiidm | SourceFormat::Jiidm
915    ) && detailed
916        .as_ref()
917        .is_some_and(|detailed| !detailed.voltage_levels.is_empty());
918    if net.buses().is_empty() && !has_dc_equipment && !has_empty_xiidm_voltage_level {
919        return Err(Error::FormatRead {
920            format,
921            message: "case has no buses or DC equipment".into(),
922        });
923    }
924    Ok(())
925}
926
927/// The source format names this crate recognizes, each with its aliases. A
928/// recognized calculation format can still be refused with guidance to the
929/// top level facade. The unknown format error prints this list, and a test walks
930/// every alias through [`routing::parse_transmission_format`] so it
931/// cannot drift from the matcher. `pypsa-csv` names a directory source and
932/// `pwb` a binary one; every other name reads file and memory sources alike.
933pub const SOURCE_FORMAT_NAMES: &str = "matpower/m, powermodels-json/powermodels/pm, \
934     egret-json/egret, psse/raw, psse34, psse35, psse-rawx/rawx, powerworld/aux, \
935     pandapower-json/pandapower/pp, pslf/epc, pypsa-csv/pypsa, pwb, goc3-json/goc3, \
936     surge-json/surge, ravens-json/ravens/mg-ravens, opfdata-json/opfdata/gridopt, xiidm/iidm, \
937     jiidm, cgmes, ucte/uct, ieee-cdf/cdf";
938
939/// An unrecognized source format token. When the token names a distribution
940/// format (`dss`, `pmd`, `bmopf`), the error points at the distribution
941/// surface instead of echoing the token: this parser reads only balanced
942/// transmission formats. Otherwise the refusal enumerates the accepted names.
943fn unknown_source_format(name: &str) -> Error {
944    if let Some(dist) = routing::parse_distribution_format(name) {
945        return Error::UnknownFormat(format!(
946            "`{}` is a distribution format, and this parser reads only balanced \
947             transmission formats; parse it through the one module family \
948             (`powerio::parse` in Rust and `parse` in the language bindings), \
949             which routes distribution formats",
950            dist.name()
951        ));
952    }
953    Error::UnknownFormat(format!("{name}; accepted names: {SOURCE_FORMAT_NAMES}"))
954}
955
956/// The case format a JSON classification selects; PowerIO IR and unrecognized
957/// shapes are refused with guidance for the caller.
958fn json_target_from_class(class: JsonClass) -> Result<TargetFormat> {
959    match class {
960        JsonClass::Module => Err(Error::UnknownFormat(
961            "JSON is PowerIO IR; decode it with `deserialize` rather than the \
962             grid exchange format parser"
963                .into(),
964        )),
965        JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
966            transmission_json_target(format)
967        }
968        JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
969            Err(Error::UnknownFormat(format!(
970                "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
971                format.name()
972            )))
973        }
974        JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
975            "ambiguous JSON markers; pass an explicit source format".into(),
976        )),
977        JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
978            "cannot infer JSON format; pass an explicit source format".into(),
979        )),
980    }
981}
982
983fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
984    match format {
985        TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
986        TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
987        TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
988        TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
989        TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
990        TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
991        TransmissionFormat::PsseRawx => Ok(TargetFormat::PsseRawx),
992        TransmissionFormat::Jiidm => Ok(TargetFormat::Jiidm),
993        other => Err(Error::UnknownFormat(format!(
994            "JSON classifier returned non-JSON transmission format `{}`",
995            other.name()
996        ))),
997    }
998}
999
1000/// One text serializer's internal output before it commits to a destination.
1001#[derive(Debug, Clone)]
1002pub(crate) struct TextEmission {
1003    pub(crate) text: String,
1004    pub(crate) diagnostics: Vec<Diagnostic>,
1005    pub(crate) fidelity: powerio_core::Fidelity,
1006}
1007
1008impl TextEmission {
1009    pub(crate) fn new(text: String, diagnostics: Diagnostics) -> Self {
1010        Self {
1011            text,
1012            diagnostics: diagnostics.into_records(),
1013            fidelity: powerio_core::Fidelity::Canonical,
1014        }
1015    }
1016
1017    /// An emission that dropped nothing, e.g. a same format echo.
1018    pub(crate) fn faithful(text: String) -> Self {
1019        let mut emission = Self::new(text, Diagnostics::new());
1020        emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
1021        emission
1022    }
1023
1024    #[cfg(test)]
1025    pub(crate) fn render_diagnostics(&self) -> Vec<String> {
1026        crate::diagnostics::render_diagnostics(&self.diagnostics)
1027    }
1028
1029    /// Record one finding after the serializer has run.
1030    pub(crate) fn push(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
1031        self.diagnostics.push(Diagnostic::of(info, message));
1032    }
1033
1034    /// Put parse diagnostics ahead of emission diagnostics.
1035    pub(crate) fn prepend(&mut self, read: Vec<Diagnostic>) {
1036        let mut records = read;
1037        records.append(&mut self.diagnostics);
1038        self.diagnostics = records;
1039    }
1040}
1041
1042/// Optional emission policies layered on top of the neutral [`BalancedNetwork`].
1043///
1044/// The default preserves the module as stated. Other options work on a cloned
1045/// network and never mutate the caller's case.
1046#[derive(Debug, Clone, Default)]
1047pub struct EmitOptions {
1048    pub missing_gen_cost: MissingGenCostPolicy,
1049    pub gen_cost_patches: Vec<GenCostPatch>,
1050}
1051
1052impl EmitOptions {
1053    #[must_use]
1054    pub fn is_default(&self) -> bool {
1055        self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
1056    }
1057}
1058
1059/// Prepare a parsed module for emission to `format`. Emitting to the source format of an
1060/// unchanged parsed module returns the retained source bytes exactly,
1061/// including a byte order mark; any other target serializes the typed value.
1062///
1063/// # Errors
1064/// [`Error::WriteUnsupported`] for a read only target, and the serializer's own
1065/// [`Error`] on a case it cannot state.
1066fn emit_text(
1067    module: &PioModule<BalancedNetwork>,
1068    format: TargetFormat,
1069) -> std::result::Result<TextEmission, powerio_core::Error> {
1070    if let Some(text) = echo_text(module, format) {
1071        return Ok(TextEmission::faithful(text));
1072    }
1073    let mut conv = emit_value_text(module.value(), format).map_err(core_error)?;
1074    warn_psse_downgrade(module, format, &mut conv);
1075    Ok(conv)
1076}
1077
1078/// Project a crate failure onto the common operation failure type.
1079pub(crate) fn core_error(error: Error) -> powerio_core::Error {
1080    let message = error.to_string();
1081    powerio_core::Error::new(error.code(), message).with_cause(error)
1082}
1083
1084/// The retained source text when emitting `module` back to its source format:
1085/// the echo that reproduces the input byte for byte. `None` sends the emission
1086/// down the semantic serialization path.
1087fn echo_text(module: &PioModule<BalancedNetwork>, target: TargetFormat) -> Option<String> {
1088    let source = module.source()?;
1089    let buffer = source.primary_buffer().ok()?;
1090    let source_format = source
1091        .format()
1092        .and_then(|format| parse_target_format(format.as_str()))?;
1093    if !same_target_format(target, source_format) {
1094        return None;
1095    }
1096    let text = std::str::from_utf8(buffer.bytes()).ok()?;
1097    // A PSS/E source echoes only when the requested revision equals the
1098    // source's own; any other revision goes through write_psse_rev so the
1099    // caller gets the layout it asked for instead of the original bytes.
1100    if let TargetFormat::Psse { rev } = target
1101        && (!matches!(rev, 33..=35)
1102            || psse::header_rev(text.trim_start_matches('\u{feff}')).ok()? != rev)
1103    {
1104        return None;
1105    }
1106    Some(text.to_owned())
1107}
1108
1109/// Whether `source` names the SINCAL family: a declared `sincal` or
1110/// `sincal-balanced` format, or an undeclared `.sinx` archive. Content never
1111/// selects the family; three-phase data is validated after the caller does.
1112fn sincal_source_declared(source: &powerio_core::Source) -> bool {
1113    match source.format() {
1114        Some(format) => {
1115            format.as_str().eq_ignore_ascii_case("sincal")
1116                || routing::parse_transmission_format(format.as_str())
1117                    == Some(TransmissionFormat::SincalBalanced)
1118        }
1119        None => std::path::Path::new(source.name())
1120            .extension()
1121            .is_some_and(|e| e.eq_ignore_ascii_case("sinx")),
1122    }
1123}
1124
1125/// The refusal a SINCAL source meets in a build without the `sincal` feature.
1126fn sincal_feature_disabled(source: &powerio_core::Source) -> powerio_core::Error {
1127    powerio_core::Error::new(
1128        &codes::REQUEST_SINCAL_FEATURE_DISABLED,
1129        "SINCAL adapters live in powerio-tnd; use powerio with its `sincal` feature or powerio_tnd::formats::sincal",
1130    )
1131    .with_source(source.clone())
1132}
1133
1134fn sincal_source_omitted(net: &BalancedNetwork) -> Option<Diagnostic> {
1135    (net.source_format() == SourceFormat::Sincal).then(|| Diagnostic::of(
1136        &codes::EMIT_SINCAL_RETAINED_SOURCE_OMITTED,
1137        "Cross-format emission includes the typed electrical profile; fault, dynamic, protection, diagram, result and other native source-only data are omitted.",
1138    ))
1139}
1140
1141/// Serialize a typed network to `format` with no source echo.
1142pub(crate) fn emit_value_text(net: &BalancedNetwork, format: TargetFormat) -> Result<TextEmission> {
1143    let mut conv = match format {
1144        TargetFormat::PowerModelsJson => write_powermodels_json(net),
1145        TargetFormat::EgretJson => write_egret_json(net),
1146        TargetFormat::Psse { rev } => {
1147            if !matches!(rev, 33..=35) {
1148                return Err(Error::Emit {
1149                    format: "PSS/E .raw",
1150                    message: format!(
1151                        "unsupported revision {rev}; emission supports only revisions 33, 34, and 35"
1152                    ),
1153                });
1154            }
1155            net.check_base_mva()?;
1156            write_psse_rev(net, rev)
1157        }
1158        TargetFormat::PsseRawx => {
1159            net.check_base_mva()?;
1160            write_rawx(net)?
1161        }
1162        TargetFormat::PowerWorld => write_powerworld(net),
1163        TargetFormat::PandapowerJson => write_pandapower_json(net),
1164        // From another source (or no retained source): canonical MATPOWER from
1165        // the folded model, which itemizes what it can't carry (HVDC, gen caps,
1166        // extras, a partial-cost case).
1167        TargetFormat::Matpower => matpower::write_matpower_conversion(net),
1168        TargetFormat::Pslf => write_pslf(net),
1169        TargetFormat::SurgeJson => write_surge_json(net),
1170        TargetFormat::RavensJson => write_ravens_json(net),
1171        TargetFormat::Goc3Json => {
1172            return Err(Error::WriteUnsupported {
1173                format: "goc3-json",
1174            });
1175        }
1176        TargetFormat::DeepMindOpfDataJson => {
1177            return Err(Error::WriteUnsupported {
1178                format: "opfdata-json",
1179            });
1180        }
1181        TargetFormat::Xiidm => write_xiidm(net)?,
1182        TargetFormat::Jiidm => write_jiidm(net)?,
1183        TargetFormat::Cgmes => {
1184            return Err(Error::WriteUnsupported { format: "cgmes" });
1185        }
1186        TargetFormat::Ucte => ucte::write_ucte(net)?,
1187    };
1188    append_emit_warnings(net, format, &mut conv);
1189    Ok(conv)
1190}
1191
1192/// Shared post-writer checks for scalar and time-series canonical emission.
1193fn append_emit_warnings(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1194    conv.diagnostics.extend(sincal_source_omitted(net));
1195    warn_normalized_tap(net, format, conv);
1196    warn_missing_reference(net, format, conv);
1197    warn_dropped_frequency(net, format, conv);
1198    warn_dropped_locations(net, format, conv);
1199    warn_dropped_transformer_charging(net, format, conv);
1200    warn_dropped_exchange_context(net, format, conv);
1201}
1202
1203/// Emit a parsed module to `format` through a destination: the one output
1204/// operation over file, memory, and (for the directory formats) folder
1205/// output. Every text target commits a single artifact — a path destination
1206/// names the exact file, a memory destination names the artifact — staged
1207/// and renamed into place so a failed emission never exposes a partial target.
1208/// The result carries the complete artifact inventory and the serializer's
1209/// findings.
1210///
1211/// # Errors
1212/// The format serializer or destination refused the operation.
1213/// failures.
1214pub fn emit(
1215    module: &PioModule<BalancedNetwork>,
1216    format: TargetFormat,
1217    destination: powerio_core::Destination,
1218) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1219    emit_with_options(module, format, &EmitOptions::default(), destination)
1220}
1221
1222/// [`emit()`] with generator cost policies.
1223///
1224/// # Errors
1225/// As [`emit()`].
1226///
1227/// # Panics
1228/// Never on external input: the fixed artifact name is valid by
1229/// construction.
1230pub fn emit_with_options(
1231    module: &PioModule<BalancedNetwork>,
1232    format: TargetFormat,
1233    options: &EmitOptions,
1234    destination: powerio_core::Destination,
1235) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1236    if format == TargetFormat::Cgmes
1237        && options.is_default()
1238        && let Some(source) = module.source()
1239        && source
1240            .format()
1241            .is_some_and(|value| value.as_str() == "cgmes")
1242    {
1243        if source.is_directory() {
1244            let mut artifacts = Vec::new();
1245            for name in source.entry_names()? {
1246                let buffer = source.buffer(&name)?;
1247                artifacts.push(powerio_core::MemoryArtifact::new(
1248                    name,
1249                    buffer.bytes().to_vec(),
1250                ));
1251            }
1252            return destination.__commit_artifacts(
1253                true,
1254                powerio_core::Fidelity::ExactSameFormat,
1255                artifacts,
1256                Vec::new(),
1257            );
1258        }
1259        if source.acquired_buffers().len() == 1 {
1260            let buffer = source.primary_buffer()?;
1261            let file_name = std::path::Path::new(buffer.name())
1262                .file_name()
1263                .and_then(|name| name.to_str())
1264                .and_then(|name| powerio_core::ArtifactPath::new(name).ok())
1265                .unwrap_or_else(|| {
1266                    powerio_core::ArtifactPath::new("case.zip")
1267                        .expect("static name is a valid artifact path")
1268                });
1269            let artifact = powerio_core::MemoryArtifact::new(file_name, buffer.bytes().to_vec());
1270            return destination.__commit_artifacts(
1271                false,
1272                powerio_core::Fidelity::ExactSameFormat,
1273                vec![artifact],
1274                Vec::new(),
1275            );
1276        }
1277    }
1278    if matches!(format, TargetFormat::Xiidm | TargetFormat::Jiidm)
1279        && options.is_default()
1280        && let Some(source) = module.source()
1281        && source
1282            .format()
1283            .and_then(|value| parse_target_format(value.as_str()))
1284            == Some(format)
1285        && source.acquired_buffers().len() == 1
1286    {
1287        let buffer = source.primary_buffer()?;
1288        let artifact = powerio_core::MemoryArtifact::new(
1289            powerio_core::ArtifactPath::new(format!("case.{}", format.extension()))
1290                .expect("the format extension names a valid artifact path"),
1291            buffer.bytes().to_vec(),
1292        );
1293        return destination.__commit_artifacts(
1294            false,
1295            powerio_core::Fidelity::ExactSameFormat,
1296            vec![artifact],
1297            Vec::new(),
1298        );
1299    }
1300    if format == TargetFormat::Cgmes {
1301        let (working, mut diagnostics) = if options.is_default() {
1302            (module.value().clone(), Vec::new())
1303        } else {
1304            apply_emit_cost_policy(module.value(), options).map_err(core_error)?
1305        };
1306        let (artifacts, format_diagnostics) = cgmes::artifacts(&working).map_err(core_error)?;
1307        diagnostics.extend(format_diagnostics.into_records());
1308        diagnostics.extend(sincal_source_omitted(&working));
1309        powerio_core::attach_retention_details(
1310            module.diagnostics(),
1311            &mut diagnostics,
1312            "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1313        )?;
1314        return destination.__commit_artifacts(
1315            true,
1316            powerio_core::Fidelity::Canonical,
1317            artifacts,
1318            diagnostics,
1319        );
1320    }
1321    let mut conv = emit_text_with_options(module, format, options)?;
1322    powerio_core::attach_retention_details(
1323        module.diagnostics(),
1324        &mut conv.diagnostics,
1325        "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1326    )?;
1327    let artifact = powerio_core::MemoryArtifact::new(
1328        powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1329        conv.text.into_bytes(),
1330    );
1331    destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
1332}
1333
1334/// Emit a parsed module as a PyPSA CSV folder through a destination. Either
1335/// destination names the output root and
1336/// every returned artifact sits below it; the whole inventory commits
1337/// atomically.
1338///
1339/// # Errors
1340/// The destination's collision and staging failures.
1341///
1342/// # Panics
1343/// Never on external input: the serializer's fixed artifact names are valid by
1344/// construction.
1345#[doc(hidden)]
1346pub fn __emit_pypsa_csv(
1347    module: &PioModule<BalancedNetwork>,
1348    destination: powerio_core::Destination,
1349) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1350    __emit_pypsa_csv_with_options(module, &EmitOptions::default(), destination)
1351}
1352
1353/// Internal bridge for the universal facade's PyPSA directory dispatch.
1354#[doc(hidden)]
1355pub fn __emit_pypsa_csv_with_options(
1356    module: &PioModule<BalancedNetwork>,
1357    options: &EmitOptions,
1358    destination: powerio_core::Destination,
1359) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1360    let (working, mut diagnostics) = if options.is_default() {
1361        (None, Vec::new())
1362    } else {
1363        let (network, diagnostics) =
1364            apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1365        (Some(network), diagnostics)
1366    };
1367    let (artifacts, format_diagnostics) =
1368        pypsa::pypsa_csv_artifacts(working.as_ref().unwrap_or(module.value()));
1369    diagnostics.extend(format_diagnostics);
1370    diagnostics.extend(sincal_source_omitted(module.value()));
1371    powerio_core::attach_retention_details(
1372        module.diagnostics(),
1373        &mut diagnostics,
1374        "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1375    )?;
1376    let artifacts = artifacts
1377        .into_iter()
1378        .map(|(name, text)| {
1379            powerio_core::MemoryArtifact::new(
1380                powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
1381                text.into_bytes(),
1382            )
1383        })
1384        .collect();
1385    destination.__commit_artifacts(
1386        true,
1387        powerio_core::Fidelity::Canonical,
1388        artifacts,
1389        diagnostics,
1390    )
1391}
1392
1393/// Prepare a parsed module with emission policies. The plain
1394/// emission behavior is preserved when `options` is default; a non-default
1395/// policy edits a copy of the typed value, so its emission never echoes source
1396/// bytes the policy no longer matches.
1397fn emit_text_with_options(
1398    module: &PioModule<BalancedNetwork>,
1399    format: TargetFormat,
1400    options: &EmitOptions,
1401) -> std::result::Result<TextEmission, powerio_core::Error> {
1402    if options.is_default() {
1403        return emit_text(module, format);
1404    }
1405    let (working, policy_warnings) =
1406        apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1407    let mut conv = emit_value_text(&working, format).map_err(core_error)?;
1408    conv.prepend(policy_warnings);
1409    Ok(conv)
1410}
1411
1412/// Apply the emission cost policy to a copy of `net` and report what it did.
1413///
1414/// Shared by the text and directory writers so both surfaces run one policy and
1415/// describe it with the same findings. The caller's network is never mutated.
1416pub(crate) fn apply_emit_cost_policy(
1417    net: &BalancedNetwork,
1418    options: &EmitOptions,
1419) -> Result<(BalancedNetwork, Vec<Diagnostic>)> {
1420    let mut working = net.clone();
1421    let report =
1422        working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
1423    let mut policy_warnings = Diagnostics::new();
1424    if report.patched > 0 {
1425        policy_warnings.push(
1426            &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1427            format!(
1428                "generator cost patch applied to {} generator(s)",
1429                report.patched
1430            ),
1431        );
1432    }
1433    if report.synthesized > 0 {
1434        policy_warnings.push(
1435            &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1436            match options.missing_gen_cost {
1437                MissingGenCostPolicy::Fill {
1438                    c2,
1439                    c1,
1440                    c0,
1441                    startup,
1442                    shutdown,
1443                } => format!(
1444                    "generator cost synthesized for {} generator(s): model 2, ncost 3, \
1445                 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
1446                    report.synthesized
1447                ),
1448                _ => unreachable!("only Fill synthesizes costs"),
1449            },
1450        );
1451    }
1452    Ok((working, policy_warnings.into_records()))
1453}
1454
1455/// Allocate a circuit id for an element keyed by `key` — a bus for loads/shunts,
1456/// or a `(from, to)` pair for branches: reuse the source-supplied `preferred` id
1457/// when it is still free on this key, else the lowest free positional id. Keeps
1458/// parallel devices distinct so the `(key, id)` uniqueness rule the PSS/E and
1459/// PSLF records require holds even when the source supplies colliding ids.
1460pub(crate) fn allocate_circuit_id<K: Ord + Clone>(
1461    preferred: Option<&str>,
1462    key: K,
1463    used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
1464) -> String {
1465    let taken = used.entry(key).or_default();
1466    if let Some(id) = preferred
1467        && taken.insert(id.to_owned())
1468    {
1469        return id.to_owned();
1470    }
1471    let mut n = 1u32;
1472    loop {
1473        let candidate = n.to_string();
1474        if taken.insert(candidate.clone()) {
1475            return candidate;
1476        }
1477        n += 1;
1478    }
1479}
1480
1481/// Warn when a PSS/E source is emitted at an older revision than its own.
1482/// The `psse` and `raw` emission aliases resolve to revision 33, so emitting a
1483/// v34/v35 source through the default target skips
1484/// the echo path (revisions differ) and re-emits the v33 layout, dropping the
1485/// modern records (12 named ratings, load DG/LOADTYPE columns, the system-wide
1486/// block) and any unmodeled section the echo would have preserved. Name the
1487/// downgrade instead of performing it silently.
1488fn warn_psse_downgrade(
1489    module: &PioModule<BalancedNetwork>,
1490    format: TargetFormat,
1491    conv: &mut TextEmission,
1492) {
1493    let source_text = module
1494        .source()
1495        .and_then(|source| source.primary_buffer().ok())
1496        .and_then(|buffer| String::from_utf8(buffer.content_bytes().to_vec()).ok());
1497    if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) = (
1498        format,
1499        module.value().source_format(),
1500        source_text.as_deref(),
1501    ) && let Ok(src_rev) = psse::header_rev(src)
1502        && src_rev > rev
1503    {
1504        conv.push(
1505                &codes::EMIT_PSSE_DOWNGRADED,
1506                format!(
1507                    "PSS/E source is revision {src_rev} but the emission target is revision {rev}; \
1508                     the older layout drops fields the source carried (emit as psse{src_rev} to keep them)"
1509                ),
1510            );
1511    }
1512}
1513
1514/// Warn when a non-default system frequency is emitted to a format with no frequency
1515/// field. PSS/E (`BASFRQ`), pandapower (`f_hz`) and the PowerIO PowerModels
1516/// and Egret extensions (`base_frequency`) carry it; MATPOWER and PowerWorld do not,
1517/// so a 50 Hz case
1518/// would parse again as the 60 Hz default. Report the loss instead.
1519fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1520    let carries_frequency = matches!(
1521        format,
1522        TargetFormat::Psse { .. }
1523            | TargetFormat::PsseRawx
1524            | TargetFormat::PandapowerJson
1525            | TargetFormat::PowerModelsJson
1526            | TargetFormat::EgretJson
1527    );
1528    if carries_frequency {
1529        return;
1530    }
1531    // UCTE-DEF has no frequency field either, but it describes the 50 Hz
1532    // synchronous area, so a 50 Hz case loses nothing and reads back as 50.
1533    if format == TargetFormat::Ucte {
1534        if (net.base_frequency() - 50.0).abs() > 1e-9 {
1535            conv.push(
1536                &format.emit_family().field_dropped,
1537                format!(
1538                    "system base frequency {} Hz dropped: UCTE-DEF describes the 50 Hz synchronous area and has no frequency field (reads back as 50 Hz)",
1539                    net.base_frequency()
1540                ),
1541            );
1542        }
1543        return;
1544    }
1545    if (net.base_frequency() - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1546        conv.push(
1547            &format.emit_family().field_dropped,
1548            format!(
1549                "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1550                net.base_frequency(),
1551                format.label(),
1552                crate::network::DEFAULT_BASE_FREQUENCY
1553            ),
1554        );
1555    }
1556}
1557
1558/// Warn when the case carries bus locations and the target has no geometry
1559/// concept. PowerWorld aux (`Latitude:1`/`Longitude:1`) and pandapower
1560/// (`geo`) carry them, and PyPSA folder emission (`x`/`y`) has its own
1561/// path. PowerModels and Egret retain PowerIO geographic extensions. MATPOWER,
1562/// PSS/E, PSLF and Surge have no matching location fields. `powerio geo extract`
1563/// emits the sidecar as the escape hatch.
1564fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1565    // PowerModels and egret JSON carry them as PowerIO extension keys.
1566    let carries_locations = matches!(
1567        format,
1568        TargetFormat::PowerWorld
1569            | TargetFormat::PandapowerJson
1570            | TargetFormat::PowerModelsJson
1571            | TargetFormat::EgretJson
1572    );
1573    if carries_locations {
1574        return;
1575    }
1576    let n = net.buses().iter().filter(|b| b.location.is_some()).count();
1577    let routed = net.branches().iter().filter(|b| b.route.is_some()).count();
1578    if n > 0 || routed > 0 {
1579        conv.push(
1580            &format.emit_family().field_dropped,
1581            format!(
1582                "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1583                 coordinate field (emit a .geo.json sidecar to keep them)",
1584                format.label()
1585            ),
1586        );
1587    }
1588}
1589
1590/// Warn when a transformer carries line charging and the target's
1591/// transformer record has no susceptance column to hold it. The PSLF `.epc`
1592/// transformer record is the one such target; PSS/E emits representable
1593/// magnetizing admittance and the MATPOWER serializers keep the legacy total
1594/// projection on the branch row, so neither drops it.
1595fn warn_dropped_transformer_charging(
1596    net: &BalancedNetwork,
1597    format: TargetFormat,
1598    conv: &mut TextEmission,
1599) {
1600    if !matches!(format, TargetFormat::Pslf) {
1601        return;
1602    }
1603    let n = net
1604        .branches()
1605        .iter()
1606        .filter(|b| b.is_transformer() && b.calc_total_charging_b() != 0.0)
1607        .count();
1608    if n > 0 {
1609        conv.push(
1610            &codes::EMIT_PSLF.field_dropped,
1611            format!(
1612                "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1613                 record cannot represent; the charging was dropped"
1614            ),
1615        );
1616    }
1617}
1618
1619/// Warn when a bus-branch case format cannot carry the grid exchange context
1620/// retained beside the calculation view. XIIDM, JIIDM, CGMES, and modern
1621/// PSS/E topology records have their own format-specific projections; the
1622/// formats selected here have no complete hierarchy or metadata model.
1623fn warn_dropped_exchange_context(
1624    net: &BalancedNetwork,
1625    format: TargetFormat,
1626    conv: &mut TextEmission,
1627) {
1628    if !matches!(
1629        format,
1630        TargetFormat::Matpower
1631            | TargetFormat::PowerModelsJson
1632            | TargetFormat::EgretJson
1633            | TargetFormat::PowerWorld
1634            | TargetFormat::PandapowerJson
1635            | TargetFormat::Pslf
1636            | TargetFormat::SurgeJson
1637            | TargetFormat::RavensJson
1638            | TargetFormat::Ucte
1639    ) {
1640        return;
1641    }
1642    let message = match format {
1643        // MG-RAVENS carries stable component identities as mRIDs, but not
1644        // the source-neutral topology hierarchy or case/solver metadata.
1645        TargetFormat::RavensJson => {
1646            exchange_context_loss_carrying(net, format.label(), CarriedContext::Identities)
1647        }
1648        // PowerModels JSON and egret JSON rows carry a source identity as
1649        // `uid`, and the document carries the case metadata, the detailed
1650        // connectivity hierarchy, and the coordinate reference, as keys those
1651        // tools leave alone.
1652        TargetFormat::PowerModelsJson | TargetFormat::EgretJson => {
1653            exchange_context_loss_carrying(net, format.label(), CarriedContext::AllButSolver)
1654        }
1655        _ => exchange_context_loss(net, format.label()),
1656    };
1657    if format == TargetFormat::EgretJson {
1658        let omitted_uids = net
1659            .switches()
1660            .iter()
1661            .map(|v| v.uid.as_deref())
1662            .chain(net.storage().iter().map(|v| v.uid.as_deref()))
1663            .chain(net.transformers_3w().iter().map(|v| v.uid.as_deref()))
1664            .chain(
1665                net.static_var_compensators()
1666                    .iter()
1667                    .map(|v| v.uid.as_deref()),
1668            )
1669            .filter(|uid| uid.is_some() && !net.uid_is_generated(*uid))
1670            .count();
1671        if omitted_uids > 0 {
1672            conv.push(&format.emit_family().field_dropped, format!(
1673                "Egret omits {omitted_uids} source-assigned component identity value(s) on switches, storage, static var compensators or three-winding transformers; retained detailed connectivity may still reference these absent electrical rows"
1674            ));
1675        }
1676    }
1677    if let Some(message) = message {
1678        conv.push(&format.emit_family().field_dropped, message);
1679    }
1680}
1681
1682/// Describe the source-neutral network context absent from a target format.
1683/// One diagnostic represents one format boundary even when the detailed model
1684/// contains many CIM or IIDM objects.
1685pub(super) fn exchange_context_loss(net: &BalancedNetwork, target: &str) -> Option<String> {
1686    exchange_context_loss_carrying(net, target, CarriedContext::Nothing)
1687}
1688
1689/// How much of the grid exchange context a target format carries, as its
1690/// own fields or as PowerIO extension keys its tools leave alone.
1691#[derive(Clone, Copy, PartialEq, Eq)]
1692enum CarriedContext {
1693    Nothing,
1694    /// Stable component identities, as MG-RAVENS mRIDs.
1695    Identities,
1696    /// Identities, case metadata, the detailed connectivity hierarchy, and
1697    /// the coordinate reference: everything but solver metadata.
1698    AllButSolver,
1699}
1700
1701/// How many records a detailed connectivity hierarchy holds.
1702fn detailed_connectivity_records(detailed: &crate::network::DetailedConnectivity) -> usize {
1703    [
1704        detailed.omitted_fields.len(),
1705        detailed.component_metadata.len(),
1706        detailed.subnetworks.len(),
1707        detailed.substations.len(),
1708        detailed.voltage_levels.len(),
1709        detailed.bus_breaker_buses.len(),
1710        detailed.calculated_buses.len(),
1711        detailed.connectivity_nodes.len(),
1712        detailed.busbar_sections.len(),
1713        detailed.junctions.len(),
1714        detailed.terminals.len(),
1715        detailed.switches.len(),
1716        detailed.internal_connections.len(),
1717        detailed.operational_limit_groups.len(),
1718        detailed.tap_changers.len(),
1719        detailed.equipment_reactive_limits.len(),
1720        detailed.boundary_lines.len(),
1721        detailed.tie_lines.len(),
1722        detailed.dc_converter_units.len(),
1723        detailed.dc_topological_nodes.len(),
1724        detailed.dc_nodes.len(),
1725        detailed.dc_grounds.len(),
1726        detailed.dc_busbars.len(),
1727        detailed.dc_lines.len(),
1728        detailed.dc_series_devices.len(),
1729        detailed.dc_switches.len(),
1730        detailed.voltage_source_converters.len(),
1731        detailed.line_commutated_converters.len(),
1732    ]
1733    .into_iter()
1734    .sum()
1735}
1736
1737/// The detailed connectivity hierarchy as the PowerIO extension value the
1738/// PowerModels and egret JSON writers state, when it holds any record. Its
1739/// references name buses by number and equipment by the `uid` those rows
1740/// carry, so the reader restores it beside the rows it describes.
1741pub(super) fn carried_detailed_connectivity(net: &BalancedNetwork) -> Option<serde_json::Value> {
1742    let detailed = net.detailed_connectivity().as_deref()?;
1743    (detailed_connectivity_records(detailed) > 0).then(|| {
1744        serde_json::to_value(detailed).expect("the detailed connectivity hierarchy is plain data")
1745    })
1746}
1747
1748#[allow(clippy::too_many_lines)] // one inventory counts every exchange-context family
1749fn exchange_context_loss_carrying(
1750    net: &BalancedNetwork,
1751    target: &str,
1752    carried: CarriedContext,
1753) -> Option<String> {
1754    let mut dropped = Vec::new();
1755    let default_metadata = crate::network::CaseMetadata::default();
1756    let carries_all = carried == CarriedContext::AllButSolver;
1757    let metadata = if carries_all {
1758        &default_metadata
1759    } else {
1760        net.case_metadata()
1761    };
1762    let mut metadata_fields = Vec::new();
1763    if metadata.case_date.is_some() {
1764        metadata_fields.push("case_date");
1765    }
1766    if metadata.forecast_distance.is_some() {
1767        metadata_fields.push("forecast_distance");
1768    }
1769    if metadata.source_model_format.is_some() {
1770        metadata_fields.push("source_model_format");
1771    }
1772    if metadata.minimum_validation_level.is_some() {
1773        metadata_fields.push("minimum_validation_level");
1774    }
1775    if !metadata_fields.is_empty() {
1776        dropped.push(format!(
1777            "case metadata fields `{}`",
1778            metadata_fields.join("`, `")
1779        ));
1780    }
1781
1782    if let Some(detailed) = net
1783        .detailed_connectivity()
1784        .as_deref()
1785        .filter(|_| !carries_all)
1786    {
1787        let records = detailed_connectivity_records(detailed);
1788        if records > 0 {
1789            dropped.push(format!(
1790                "the `detailed_connectivity` hierarchy and topology ({records} record(s))"
1791            ));
1792        }
1793    }
1794
1795    let source_uids = net
1796        .buses()
1797        .iter()
1798        .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1799        .count()
1800        + net
1801            .loads()
1802            .iter()
1803            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1804            .count()
1805        + net
1806            .shunts()
1807            .iter()
1808            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1809            .count()
1810        + net
1811            .static_var_compensators()
1812            .iter()
1813            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1814            .count()
1815        + net
1816            .branches()
1817            .iter()
1818            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1819            .count()
1820        + net
1821            .switches()
1822            .iter()
1823            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1824            .count()
1825        + net
1826            .generators()
1827            .iter()
1828            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1829            .count()
1830        + net
1831            .storage()
1832            .iter()
1833            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1834            .count()
1835        + net
1836            .hvdc()
1837            .iter()
1838            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1839            .count()
1840        + net
1841            .transformers_3w()
1842            .iter()
1843            .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1844            .count()
1845        + net
1846            .areas()
1847            .iter()
1848            .filter(|value| value.uid.is_some())
1849            .count();
1850    if source_uids > 0 && carried == CarriedContext::Nothing {
1851        dropped.push(format!(
1852            "{source_uids} source-assigned stable component identity value(s)"
1853        ));
1854    }
1855    if net.geo().is_some() && !carries_all {
1856        dropped.push("geographic coordinate reference metadata".to_owned());
1857    }
1858    if net.solver().is_some() {
1859        dropped.push("solver and solution-control metadata".to_owned());
1860    }
1861    if dropped.is_empty() {
1862        return None;
1863    }
1864    Some(format!(
1865        "{target} has no complete source-neutral grid exchange context; dropped {}",
1866        dropped.join(", ")
1867    ))
1868}
1869
1870pub(super) fn branch_rating_set_drop_warning(
1871    target: &str,
1872    branch_index: usize,
1873    branch: &Branch,
1874    rating: &BranchRatingSet,
1875) -> String {
1876    format!(
1877        "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1878        branch_index + 1,
1879        branch.from,
1880        branch.to,
1881        rating.name,
1882        rating.rate_mva,
1883        target
1884    )
1885}
1886
1887/// Warn once when elements carry passthrough extras `target`'s writer does not
1888/// replay. `consumed` is the writer's own rule: the keys it reads back into a
1889/// record. Everything else was retained by a reader because the source stated
1890/// more than a rewrite would synthesize, so dropping it without saying so is
1891/// an undeclared loss (#330). The line names every key it drops, because a key
1892/// is the field the target has no record for and a bare count states no loss.
1893pub(super) fn warn_dropped_extras(
1894    family: &'static EmitFamily,
1895    target: &str,
1896    net: &BalancedNetwork,
1897    consumed: impl Fn(&str) -> bool,
1898    warnings: &mut Diagnostics,
1899) {
1900    let mut dropped = 0usize;
1901    let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1902    for extras in net
1903        .buses()
1904        .iter()
1905        .map(|e| &e.extras)
1906        .chain(net.branches().iter().map(|e| &e.extras))
1907        .chain(net.loads().iter().map(|e| &e.extras))
1908        .chain(net.shunts().iter().map(|e| &e.extras))
1909        .chain(net.switches().iter().map(|e| &e.extras))
1910        .chain(net.storage().iter().map(|e| &e.extras))
1911        .chain(net.hvdc().iter().map(|e| &e.extras))
1912        .chain(net.transformers_3w().iter().map(|e| &e.extras))
1913    {
1914        let mut carries = false;
1915        for key in extras.keys().filter(|key| !consumed(key)) {
1916            carries = true;
1917            keys.insert(key.as_str());
1918        }
1919        if carries {
1920            dropped += 1;
1921        }
1922    }
1923    if dropped > 0 {
1924        let named = keys
1925            .iter()
1926            .take(EXTRAS_KEYS_NAMED)
1927            .copied()
1928            .collect::<Vec<_>>()
1929            .join("`, `");
1930        let remainder = keys.len().saturating_sub(EXTRAS_KEYS_NAMED);
1931        let remainder = if remainder > 0 {
1932            format!(" and {remainder} more")
1933        } else {
1934            String::new()
1935        };
1936        warnings.push(
1937            &family.extras_dropped,
1938            format!(
1939                "{dropped} element(s) state field(s) `{named}`{remainder} that no {target} record \
1940                 holds; dropped"
1941            ),
1942        );
1943    }
1944}
1945
1946/// How many extras keys one dropped-extras line names before it counts the
1947/// rest. Six keeps the line readable while naming every key the vendored
1948/// fixtures reach.
1949const EXTRAS_KEYS_NAMED: usize = 6;
1950
1951/// Warn about the area data a target with no area table cannot state.
1952///
1953/// A target whose bus row carries the area number preserves membership and
1954/// loses only attributes of the area record. A target with no bus area field
1955/// loses the membership as well, so even an otherwise empty area is reported.
1956pub(super) fn warn_dropped_areas(
1957    family: &'static EmitFamily,
1958    target: &str,
1959    writes_bus_area: bool,
1960    net: &BalancedNetwork,
1961    warnings: &mut Diagnostics,
1962) {
1963    if !writes_bus_area {
1964        if !net.areas().is_empty() {
1965            let count = net.areas().len();
1966            let noun = if count == 1 { "record" } else { "records" };
1967            warnings.push(
1968                &family.areas_dropped,
1969                format!(
1970                    "{count} area {noun} dropped: {target} writes neither an area table nor a bus area number"
1971                ),
1972            );
1973        }
1974        return;
1975    }
1976    let mut fields = std::collections::BTreeSet::new();
1977    let mut stated = 0usize;
1978    for area in net.areas() {
1979        let mut states_any = false;
1980        for field in stated_area_fields(area) {
1981            fields.insert(field);
1982            states_any = true;
1983        }
1984        stated += usize::from(states_any);
1985    }
1986    if fields.is_empty() {
1987        return;
1988    }
1989    let total = net.areas().len();
1990    let subject = if total == 1 {
1991        "the area record states".to_owned()
1992    } else {
1993        format!("{stated} of {total} area records state")
1994    };
1995    warnings.push(
1996        &family.areas_dropped,
1997        format!(
1998            "{subject} {}: {target} bus rows carry the area number, but {target} has no record \
1999             for the area's attributes",
2000            fields.into_iter().collect::<Vec<_>>().join(", ")
2001        ),
2002    );
2003}
2004
2005/// The attributes an area record states beyond its number, by the names the
2006/// dropped area warning reports them under.
2007fn stated_area_fields(area: &crate::Area) -> impl Iterator<Item = &'static str> {
2008    [
2009        (area.slack_bus.is_some(), "swing bus"),
2010        (area.net_interchange != 0.0, "scheduled net interchange"),
2011        (area.tolerance != 0.0, "interchange tolerance"),
2012        (area.name.is_some(), "name"),
2013        (area.uid.is_some(), "source identity"),
2014        (area.area_type.is_some(), "classification"),
2015    ]
2016    .into_iter()
2017    .filter_map(|(stated, field)| stated.then_some(field))
2018}
2019
2020pub(super) fn warn_extra_branch_rating_sets(
2021    family: &'static EmitFamily,
2022    target: &str,
2023    net: &BalancedNetwork,
2024    warnings: &mut Diagnostics,
2025) {
2026    for (branch_index, branch) in net.branches().iter().enumerate() {
2027        for rating in &branch.rating_sets {
2028            warnings.push(
2029                &family.rating_set_dropped,
2030                branch_rating_set_drop_warning(target, branch_index, branch, rating),
2031            );
2032        }
2033    }
2034}
2035
2036/// Warn for every three winding transformer winding rating set `target` has
2037/// no field for, the winding counterpart of [`warn_extra_branch_rating_sets`].
2038pub(super) fn warn_winding_rating_sets(
2039    family: &'static EmitFamily,
2040    target: &str,
2041    net: &BalancedNetwork,
2042    warnings: &mut Diagnostics,
2043) {
2044    for transformer in net.transformers_3w() {
2045        let [i, j, k] = transformer.windings.each_ref().map(|winding| winding.bus);
2046        for index in 0..3 {
2047            for rating in transformer.winding_rating_sets(index) {
2048                warnings.push(
2049                    &family.rating_set_dropped,
2050                    format!(
2051                        "three winding transformer {i}-{j}-{k} winding {} rating set {}={} MVA dropped: {target} has no field for winding rating sets beyond rate_a, rate_b, and rate_c",
2052                        index + 1,
2053                        rating.name,
2054                        rating.rate_mva
2055                    ),
2056                );
2057            }
2058        }
2059    }
2060}
2061
2062/// Warn once when the `target` writer states one status per three winding
2063/// transformer, so a winding out of service in an in service transformer is
2064/// written in service.
2065pub(super) fn warn_winding_status_collapsed(
2066    family: &'static EmitFamily,
2067    target: &str,
2068    net: &BalancedNetwork,
2069    warnings: &mut Diagnostics,
2070) {
2071    let windings = net
2072        .transformers_3w()
2073        .iter()
2074        .filter(|transformer| transformer.in_service)
2075        .flat_map(Transformer3W::winding_in_service)
2076        .filter(|in_service| !in_service)
2077        .count();
2078    if windings > 0 {
2079        warnings.push(
2080            &family.value_collapsed,
2081            format!(
2082                "{windings} out of service three winding transformer winding(s) written in service: the {target} writer states one status per three winding transformer"
2083            ),
2084        );
2085    }
2086}
2087
2088/// The declared format ID for a caller-supplied token. Tokens are matched
2089/// case insensitively and accept the historical underscore spelling of a
2090/// hyphenated alias; the ID itself keeps the stable lower case hyphen
2091/// grammar.
2092pub fn parse_format_id(
2093    token: &str,
2094) -> std::result::Result<powerio_core::FormatId, powerio_core::Error> {
2095    powerio_core::FormatId::new(token.to_ascii_lowercase().replace('_', "-"))
2096}
2097
2098/// Warn when a network with no reference (slack) bus converts to a format
2099/// whose solvers require one. PowerWorld `.pwb` is the one source that
2100/// systematically lacks the designation (the binary does not store it), so
2101/// the silent case would be common; `to_normalized` synthesizes a slack at
2102/// the largest pmax in service generator bus for consumers that need one.
2103fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
2104    let needs_ref = matches!(
2105        format,
2106        TargetFormat::Matpower
2107            | TargetFormat::Psse { .. }
2108            | TargetFormat::PsseRawx
2109            | TargetFormat::PowerModelsJson
2110            | TargetFormat::PandapowerJson
2111            | TargetFormat::Pslf
2112            | TargetFormat::SurgeJson
2113    );
2114    if needs_ref && let Some(message) = missing_reference_warning(net) {
2115        conv.push(&format.emit_family().reference_missing, message);
2116    }
2117}
2118
2119/// The slackless network warning itself, shared with the PyPSA folder emitter.
2120pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
2121    (!net.buses().iter().any(|b| b.kind == BusType::Ref)).then(|| {
2122        "no reference (slack) bus in the source network; power flow tools \
2123         reject such cases; to_normalized synthesizes a slack at the \
2124         largest pmax in service generator bus"
2125            .to_string()
2126    })
2127}
2128
2129/// A normalized network has its tap canonicalized to `1.0` on every line (the
2130/// `0 → 1` rule), but [`Branch::is_transformer`](crate::network::Branch::is_transformer),
2131/// the test these writers use to split lines from transformers, keys off
2132/// `tap != 0`. So a normalized line is written into the transformer section/type.
2133/// The power flow is identical (a unity-ratio, zero-shift transformer equals a
2134/// line), but the label is not, so report the fidelity loss rather than relabel
2135/// it silently. MATPOWER has no separate transformer representation (just a `TAP`
2136/// column), so it is exempt.
2137// `tap == 1.0` / `shift == 0.0` are exact by construction: normalization sets a
2138// line's tap from `calc_effective_tap()` (the literal `1.0`) and its shift from
2139// `0.0 * DEG_TO_RAD` (exactly `0.0`), so an epsilon compare would be wrong here.
2140#[allow(clippy::float_cmp)]
2141fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
2142    if matches!(format, TargetFormat::Matpower) {
2143        return;
2144    }
2145    if let Some(message) = normalized_tap_warning(net) {
2146        conv.push(&format.emit_family().element_relabeled, message);
2147    }
2148}
2149
2150/// The normalized-label warning itself, shared with the PyPSA folder writer.
2151// `tap == 1.0` / `shift == 0.0` are exact by construction (see
2152// `warn_normalized_tap`), so an epsilon compare would be wrong here.
2153#[allow(clippy::float_cmp)]
2154pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
2155    if !net.is_normalized() {
2156        return None;
2157    }
2158    // After normalization a line (raw tap 0) and a unity-ratio transformer (raw
2159    // tap 1) both read as tap 1.0 / shift 0.0, so they cannot be told apart. Count
2160    // them together as the branches whose line/transformer label is now ambiguous.
2161    let ambiguous = net
2162        .branches()
2163        .iter()
2164        .filter(|b| b.tap == 1.0 && b.shift == 0.0)
2165        .count();
2166    (ambiguous > 0).then(|| {
2167        format!(
2168            "normalized network: {ambiguous} branch(es) have unit tap and no phase \
2169             shift, so the line/transformer label is not preserved (the power flow \
2170             is identical)"
2171        )
2172    })
2173}
2174
2175/// True when `value` is set and deviates from `reference`: the shared test for
2176/// "does this rating column carry information the target cannot" used by the
2177/// rate_b/rate_c drop warnings.
2178pub(super) fn nonzero_differs(value: f64, reference: f64) -> bool {
2179    value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
2180}
2181
2182/// Set a bus's kind through the `bus_pos` index, leaving Isolated buses alone.
2183/// Shared by the readers that derive bus kinds from generator/slack tables.
2184pub(crate) fn set_bus_kind(
2185    buses: &mut [Bus],
2186    bus_pos: &HashMap<BusId, usize>,
2187    bus: BusId,
2188    kind: BusType,
2189) {
2190    if let Some(&idx) = bus_pos.get(&bus)
2191        && buses[idx].kind != BusType::Isolated
2192    {
2193        buses[idx].kind = kind;
2194    }
2195}
2196
2197/// `base_kv` of a bus through the `bus_pos` index; 0.0 for an unknown bus.
2198pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
2199    bus_pos
2200        .get(&bus)
2201        .and_then(|&i| buses.get(i))
2202        .map_or(0.0, |b| b.base_kv)
2203}
2204
2205/// Replace characters that would corrupt a quoted or delimited field with
2206/// `replacement`, so a free-form name can't shift or truncate the record it sits
2207/// in. `forbidden` lists the destination's quote, delimiter, and comment chars.
2208/// Returns the value borrowed unchanged when it holds none of them, so the common
2209/// clean-name path allocates nothing.
2210///
2211/// Each text writer calls this at its quoting seam and warns when the result
2212/// differs from the input (the substitution silently alters operator-facing
2213/// names): the PSS/E single-quoted bus name and the PowerWorld double-quoted bus
2214/// name both interpolate a `BalancedNetwork` name straight into a quoted field, where an
2215/// embedded quote (or, for PSS/E, the `/` inline-comment delimiter) would shift
2216/// every later column of the record.
2217/// A line terminator is always replaced, whatever `forbidden` holds: no text
2218/// record format can carry one inside a field, so an embedded `\n` does not
2219/// shift a column, it ends the record and makes everything after it parse as
2220/// a new one. A crafted name could otherwise forge whole records in the
2221/// written file.
2222pub(crate) fn sanitize_quoted<'a>(
2223    value: &'a str,
2224    forbidden: &[char],
2225    replacement: char,
2226) -> std::borrow::Cow<'a, str> {
2227    let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
2228    if value.contains(breaks) {
2229        value
2230            .chars()
2231            .map(|c| if breaks(c) { replacement } else { c })
2232            .collect::<String>()
2233            .into()
2234    } else {
2235        std::borrow::Cow::Borrowed(value)
2236    }
2237}
2238
2239/// Impedance base `v_kv² / base_mva`; 1.0 when either base is missing, so a
2240/// per-unit ↔ ohm conversion on it is the identity.
2241pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
2242    if v_kv > 0.0 && base_mva > 0.0 {
2243        v_kv * v_kv / base_mva
2244    } else {
2245        1.0
2246    }
2247}
2248
2249/// Whether two case targets identify the same physical format. PSS/E revisions
2250/// share a family here; the retained header check above decides whether the
2251/// requested revision is byte exact.
2252fn same_target_format(requested: TargetFormat, source: TargetFormat) -> bool {
2253    requested == source
2254        || matches!(
2255            (requested, source),
2256            (TargetFormat::Psse { .. }, TargetFormat::Psse { .. })
2257        )
2258}
2259
2260/// JSON number for a finite `f64`; `Value::Null` for `NaN`/`±Inf`.
2261pub(crate) fn jnum(x: f64) -> Value {
2262    serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
2263}
2264
2265/// Serialize a built JSON tree into a [`TextEmission`], appending one warning that
2266/// names every field where a non-finite `f64` was written as `null` (JSON has no
2267/// `±Inf`/`NaN`). Shared by the JSON writers.
2268pub(crate) fn finish(
2269    family: &'static EmitFamily,
2270    root: Map<String, Value>,
2271    mut warnings: Diagnostics,
2272) -> TextEmission {
2273    let value = Value::Object(root);
2274    warn_nonfinite_json(family, &value, &mut warnings);
2275    let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
2276    TextEmission::new(text, warnings)
2277}
2278
2279/// Check each scalar before a temporal writer moves fields into value arrays.
2280fn warn_nonfinite_json(family: &'static EmitFamily, value: &Value, warnings: &mut Diagnostics) {
2281    let mut nulls = BTreeSet::new();
2282    collect_null_keys(value, &mut nulls);
2283    if !nulls.is_empty() {
2284        let finding = Diagnostic::of(
2285            &family.not_a_number,
2286            format!(
2287                "non-finite numeric values written as JSON null in field(s): {}",
2288                nulls.into_iter().collect::<Vec<_>>().join(", ")
2289            ),
2290        );
2291        if !warnings.records().contains(&finding) {
2292            warnings.record(finding);
2293        }
2294    }
2295}
2296
2297/// Collect the names of object keys whose value is `null`, anywhere in the tree.
2298fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
2299    match value {
2300        Value::Object(map) => {
2301            for (key, val) in map {
2302                if val.is_null() {
2303                    out.insert(key.clone());
2304                } else {
2305                    collect_null_keys(val, out);
2306                }
2307            }
2308        }
2309        Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
2310        _ => {}
2311    }
2312}
2313
2314/// Test harness for parser and emitter fixtures.
2315#[cfg(test)]
2316pub(crate) mod test_parse {
2317    use super::*;
2318
2319    #[derive(Debug)]
2320    pub(crate) struct TestParsed {
2321        pub network: BalancedNetwork,
2322        pub diagnostics: Vec<Diagnostic>,
2323    }
2324
2325    impl TestParsed {
2326        pub(crate) fn render_diagnostics(&self) -> Vec<String> {
2327            crate::diagnostics::render_diagnostics(&self.diagnostics)
2328        }
2329    }
2330
2331    fn declared(
2332        source: powerio_core::Source,
2333        from: Option<&str>,
2334    ) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
2335        match from {
2336            None => Ok(source),
2337            Some(token) => Ok(source.with_format(powerio_core::FormatId::new(
2338                token.to_ascii_lowercase().replace('_', "-"),
2339            )?)),
2340        }
2341    }
2342
2343    pub(crate) fn parse_file(
2344        path: impl AsRef<std::path::Path>,
2345        from: Option<&str>,
2346    ) -> std::result::Result<TestParsed, powerio_core::Error> {
2347        let source = declared(powerio_core::Source::open(path.as_ref())?, from)?;
2348        parse(source).map(|module| TestParsed {
2349            diagnostics: module.diagnostics().to_vec(),
2350            network: module.into_value(),
2351        })
2352    }
2353
2354    pub(crate) fn parse_str(
2355        text: &str,
2356        from: &str,
2357    ) -> std::result::Result<TestParsed, powerio_core::Error> {
2358        let source = declared(
2359            powerio_core::Source::from_memory("<memory>", text.as_bytes().to_vec())?,
2360            Some(from),
2361        )?;
2362        parse(source).map(|module| TestParsed {
2363            diagnostics: module.diagnostics().to_vec(),
2364            network: module.into_value(),
2365        })
2366    }
2367}
2368
2369#[cfg(test)]
2370mod tests {
2371    use super::test_parse::{parse_file, parse_str};
2372    use super::*;
2373    use crate::network::SourceFormat;
2374
2375    #[test]
2376    fn sanitize_quoted_always_replaces_line_terminators() {
2377        // A terminator ends the record, so it is replaced whatever the
2378        // caller's delimiter set holds: a name carrying one could otherwise
2379        // forge whole records in a written .raw/.aux/.epc.
2380        for forbidden in [&[][..], &['\''][..], &['"'][..]] {
2381            let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
2382            assert!(
2383                !out.contains('\n') && !out.contains('\r'),
2384                "terminator survived with forbidden={forbidden:?}: {out:?}"
2385            );
2386        }
2387        // A clean value is still borrowed, not copied.
2388        assert!(matches!(
2389            sanitize_quoted("clean name", &['\''], ' '),
2390            std::borrow::Cow::Borrowed(_)
2391        ));
2392    }
2393
2394    #[test]
2395    fn dss_extension_error_names_the_distribution_surface() {
2396        let path = std::env::temp_dir().join(format!(
2397            "powerio-dss-surface-{}-feeder.dss",
2398            std::process::id()
2399        ));
2400        std::fs::write(&path, "New Circuit.feeder\n").unwrap();
2401        let err = parse_file(&path, None).unwrap_err();
2402        let _ = std::fs::remove_file(&path);
2403        assert!(err.to_string().contains("distribution"), "got: {err}");
2404    }
2405
2406    #[test]
2407    fn io_error_names_the_path() {
2408        let path =
2409            std::env::temp_dir().join(format!("powerio-no-such-case-{}.m", std::process::id()));
2410        let err = parse_file(&path, None).unwrap_err();
2411        assert_eq!(err.category(), powerio_core::ErrorCategory::Io);
2412        let msg = err.to_string();
2413        assert!(
2414            msg.contains(&path.display().to_string()),
2415            "the io failure must name the path: {msg}"
2416        );
2417    }
2418
2419    #[test]
2420    fn a_directory_is_refused_as_a_directory() {
2421        // A versioned dataset directory: extension inference would read ".07"
2422        // off the name and misdiagnose the mistake as a format problem.
2423        let dir = std::env::temp_dir().join(format!("pglib-opf-23.07-{}", std::process::id()));
2424        std::fs::create_dir_all(&dir).unwrap();
2425        let err = parse_file(&dir, None).unwrap_err();
2426        std::fs::remove_dir_all(&dir).unwrap();
2427        let msg = err.to_string();
2428        assert!(msg.contains("is a directory"), "got: {msg}");
2429        assert!(msg.contains(&dir.display().to_string()), "got: {msg}");
2430        assert!(msg.contains("PyPSA CSV folder"), "got: {msg}");
2431    }
2432
2433    #[test]
2434    fn unknown_format_error_lists_the_accepted_names() {
2435        let err = parse_str("anything", "not-a-format").unwrap_err();
2436        let msg = err.to_string();
2437        assert!(msg.contains("not-a-format"), "got: {msg}");
2438        assert!(msg.contains("accepted names:"), "got: {msg}");
2439        assert!(msg.contains(SOURCE_FORMAT_NAMES), "got: {msg}");
2440    }
2441
2442    #[test]
2443    fn the_accepted_name_list_matches_the_matcher() {
2444        use routing::TransmissionFormat as TF;
2445        // Every alias in the printed list resolves.
2446        let mut canonical = Vec::new();
2447        for clause in SOURCE_FORMAT_NAMES.split(", ") {
2448            for (i, alias) in clause.split('/').enumerate() {
2449                let resolved = routing::parse_transmission_format(alias);
2450                assert!(
2451                    resolved.is_some(),
2452                    "listed alias `{alias}` does not resolve"
2453                );
2454                if i == 0 {
2455                    canonical.push(resolved.unwrap());
2456                }
2457            }
2458        }
2459        // Every parseable format is listed. Gridfm is the one matcher entry
2460        // with no parse_file arm (datasets go through the read_dir surface).
2461        for format in [
2462            TF::Matpower,
2463            TF::PowerModelsJson,
2464            TF::EgretJson,
2465            TF::Psse,
2466            TF::Psse34,
2467            TF::Psse35,
2468            TF::PowerWorld,
2469            TF::PandapowerJson,
2470            TF::PypsaCsv,
2471            TF::Pslf,
2472            TF::Pwb,
2473            TF::Goc3Json,
2474            TF::SurgeJson,
2475            TF::RavensJson,
2476            TF::DeepMindOpfDataJson,
2477            TF::Ucte,
2478            TF::Xiidm,
2479            TF::Jiidm,
2480            TF::Cgmes,
2481            TF::IeeeCdf,
2482        ] {
2483            assert!(
2484                canonical.contains(&format),
2485                "{} is missing from SOURCE_FORMAT_NAMES",
2486                format.name()
2487            );
2488        }
2489    }
2490
2491    #[test]
2492    fn a_case_with_generators_and_no_cost_data_warns() {
2493        let costless = "\
2494function mpc = nocost
2495mpc.version = '2';
2496mpc.baseMVA = 100;
2497mpc.bus = [
2498\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2499\t2\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2500];
2501mpc.gen = [
2502\t1\t60\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2503];
2504mpc.branch = [
2505\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2506];
2507";
2508        // The parse itself stays silent: whether a case carries costs is the
2509        // case's business, and a conversion leg must not count it. The
2510        // solver-ready copy is where a zero objective becomes real.
2511        let parsed = parse_str(costless, "matpower").unwrap();
2512        assert!(
2513            parsed.render_diagnostics().is_empty(),
2514            "{:?}",
2515            parsed.render_diagnostics()
2516        );
2517        let normalized = parsed
2518            .network
2519            .to_normalized_with_options(&crate::NormalizeOptions::default())
2520            .unwrap();
2521        let absent: Vec<_> = normalized
2522            .diagnostics
2523            .iter()
2524            .filter(|d| d.code() == "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT")
2525            .collect();
2526        assert_eq!(absent.len(), 1, "{:?}", normalized.warnings);
2527        assert!(absent[0].message().contains("no cost data"), "{absent:?}");
2528        assert!(absent[0].message().contains("1 in-service"), "{absent:?}");
2529
2530        // The same case with a gencost table is silent.
2531        let costed = format!("{costless}mpc.gencost = [\n\t2\t0\t0\t3\t0.01\t40\t0;\n];\n");
2532        let parsed = parse_str(&costed, "matpower").unwrap();
2533        let normalized = parsed
2534            .network
2535            .to_normalized_with_options(&crate::NormalizeOptions::default())
2536            .unwrap();
2537        assert!(
2538            normalized
2539                .diagnostics
2540                .iter()
2541                .all(|d| d.code() != "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT"),
2542            "{:?}",
2543            normalized.warnings
2544        );
2545    }
2546
2547    #[test]
2548    fn distribution_from_token_error_names_the_distribution_surface() {
2549        for token in ["dss", "pmd", "bmopf"] {
2550            let err = parse_str("anything", token).unwrap_err();
2551            assert!(
2552                err.to_string().contains("one module family"),
2553                "{token}: {err}"
2554            );
2555        }
2556        // A genuinely unknown token still echoes plainly.
2557        let err = parse_str("anything", "nonesuch").unwrap_err();
2558        assert!(err.to_string().contains("nonesuch"));
2559    }
2560
2561    #[test]
2562    fn byte_order_mark_is_retained_and_echoed() {
2563        // Windows tooling saves case files with a UTF-8 byte order mark. The
2564        // parser decodes a mark free slice of the one retained buffer, and an
2565        // unchanged same format write reproduces the original bytes, mark
2566        // included.
2567        let case = "\u{feff}function mpc = t\n\
2568                    mpc.version = '2';\n\
2569                    mpc.baseMVA = 100;\n\
2570                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2571                    mpc.gen = [];\n\
2572                    mpc.branch = [];\n";
2573        let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2574        let module = parse(source.with_format(parse_format_id("matpower").unwrap())).unwrap();
2575        assert_eq!(module.value().buses().len(), 1);
2576        assert!(
2577            module.diagnostics().is_empty(),
2578            "{:?}",
2579            module.diagnostics()
2580        );
2581        let echo = emit_text(&module, TargetFormat::Matpower).unwrap();
2582        assert_eq!(echo.text, case, "the echo reproduces the mark exactly");
2583    }
2584
2585    #[test]
2586    fn canonical_format_bypasses_same_format_matpower_echo() {
2587        let case = "function mpc = t\n\
2588                    % a comment the canonical writer does not keep\n\
2589                    mpc.version = '2';\n\
2590                    mpc.baseMVA = 100;\n\
2591                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2592                    mpc.gen = [];\n\
2593                    mpc.branch = [];\n";
2594        let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2595        let module =
2596            parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap())).unwrap();
2597        assert_eq!(
2598            emit_text(&module, TargetFormat::Matpower).unwrap().text,
2599            case
2600        );
2601
2602        let net = module.into_value();
2603        let canonical = emit_value_text(&net, TargetFormat::Matpower).unwrap();
2604        assert_ne!(canonical.text, case);
2605        let reparsed = parse_str(&canonical.text, "matpower").unwrap();
2606        assert_eq!(reparsed.network.buses().len(), 1);
2607    }
2608
2609    #[test]
2610    fn bus_branch_formats_report_dropped_exchange_context_once_or_carry_it() {
2611        let case = "function mpc = t\n\
2612                    mpc.version = '2';\n\
2613                    mpc.baseMVA = 100;\n\
2614                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2615                    mpc.gen = [];\n\
2616                    mpc.branch = [];\n";
2617        let mut net = parse_str(case, "matpower").unwrap().network;
2618        *net.detailed_connectivity_mut() =
2619            Some(std::sync::Arc::new(crate::DetailedConnectivity::default()));
2620        assert!(exchange_context_loss(&net, "PowerModels JSON").is_none());
2621
2622        net.case_metadata_mut().case_date = Some("2025-01-02T03:04:05Z".into());
2623        net.buses_mut()[0].uid = Some("source-bus".into());
2624        let mut detailed = crate::DetailedConnectivity::default();
2625        detailed.substations.push(crate::Substation {
2626            component: powerio_core::ComponentId::new("substation", "source-substation").unwrap(),
2627            country: None,
2628            operator: None,
2629            geographical_tags: Vec::new(),
2630        });
2631        *net.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2632
2633        let emitted = emit_value_text(&net, TargetFormat::PowerModelsJson).unwrap();
2634        let context = emitted
2635            .diagnostics
2636            .iter()
2637            .filter(|diagnostic| {
2638                diagnostic.code() == "EMIT.POWERMODELS.FIELD_DROPPED"
2639                    && diagnostic.message().contains("grid exchange context")
2640            })
2641            .collect::<Vec<_>>();
2642        // PowerModels JSON carries the case metadata, a source identity, and
2643        // the hierarchy as keys PowerModels ignores, so it loses none of them.
2644        assert!(context.is_empty(), "{:?}", emitted.render_diagnostics());
2645        let reparsed = parse_str(&emitted.text, "powermodels-json")
2646            .unwrap()
2647            .network;
2648        assert_eq!(
2649            reparsed.case_metadata().case_date.as_deref(),
2650            Some("2025-01-02T03:04:05Z")
2651        );
2652        assert_eq!(reparsed.buses()[0].uid.as_deref(), Some("source-bus"));
2653        assert_eq!(
2654            reparsed.detailed_connectivity(),
2655            net.detailed_connectivity()
2656        );
2657
2658        let ravens = emit_value_text(&net, TargetFormat::RavensJson).unwrap();
2659        let context = ravens
2660            .diagnostics
2661            .iter()
2662            .filter(|diagnostic| {
2663                diagnostic.code() == "EMIT.RAVENS.FIELD_DROPPED"
2664                    && diagnostic.message().contains("grid exchange context")
2665            })
2666            .collect::<Vec<_>>();
2667        assert_eq!(context.len(), 1, "{:?}", ravens.render_diagnostics());
2668        let message = context[0].message();
2669        assert!(message.contains("case_date"), "{message}");
2670        assert!(message.contains("detailed_connectivity"), "{message}");
2671        assert!(!message.contains("source-assigned"), "{message}");
2672        let reparsed = parse_str(&ravens.text, "ravens-json").unwrap().network;
2673        assert_eq!(reparsed.buses()[0].uid.as_deref(), Some("source-bus"));
2674
2675        *net.case_metadata_mut() = crate::network::CaseMetadata::default();
2676        *net.detailed_connectivity_mut() = None;
2677        let ravens = emit_value_text(&net, TargetFormat::RavensJson).unwrap();
2678        assert!(
2679            ravens
2680                .diagnostics
2681                .iter()
2682                .all(|diagnostic| !diagnostic.message().contains("grid exchange context")),
2683            "retained identities alone are not a loss: {:?}",
2684            ravens.render_diagnostics()
2685        );
2686    }
2687
2688    #[test]
2689    fn source_format_strings_round_trip_to_a_target() {
2690        // The bindings expose `source_format` as its `name()` token, and
2691        // `emit` routes that string back through `parse_target_format`.
2692        // Every writable source format must resolve.
2693        for (sf, want) in [
2694            (SourceFormat::Matpower, TargetFormat::Matpower),
2695            (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
2696            (SourceFormat::EgretJson, TargetFormat::EgretJson),
2697            (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
2698            (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
2699            (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
2700            (SourceFormat::Pslf, TargetFormat::Pslf),
2701            (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
2702            (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
2703            (SourceFormat::RavensJson, TargetFormat::RavensJson),
2704            (SourceFormat::Ucte, TargetFormat::Ucte),
2705            (
2706                SourceFormat::DeepMindOpfDataJson,
2707                TargetFormat::DeepMindOpfDataJson,
2708            ),
2709        ] {
2710            let token = sf.name();
2711            assert_eq!(
2712                parse_target_format(token),
2713                Some(want),
2714                "source_format {token:?} did not round-trip"
2715            );
2716        }
2717        // The derived/in-memory source formats have no writer target, and
2718        // neither do the read only .pwb binary and the IEEE CDF text.
2719        for sf in [
2720            SourceFormat::InMemory,
2721            SourceFormat::Normalized,
2722            SourceFormat::Gridfm,
2723            SourceFormat::PypsaCsv,
2724            SourceFormat::PowerWorldBinary,
2725            SourceFormat::IeeeCdf,
2726        ] {
2727            assert_eq!(parse_target_format(sf.name()), None);
2728        }
2729    }
2730
2731    #[test]
2732    fn unsupported_indexed_assignment_warning_preserves_public_literals_and_echo() {
2733        let case = "function mpc = t\n\
2734                    mpc.version = '2';\n\
2735                    mpc.baseMVA = 100;\n\
2736                    mpc.bus = [1 3 12 4 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2737                    mpc.gen = [];\n\
2738                    mpc.branch = [];\n\
2739                    mpc.bus(:, [3 4]) = mpc.bus(:, [3 4]) / unknown_scale;\n";
2740        let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2741        let module = parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap()))
2742            .expect("literal case parses with a diagnostic");
2743        assert!(
2744            module.diagnostics().iter().any(|d| {
2745                d.code() == "READ.MATPOWER.INDEXED_ASSIGNMENT"
2746                    && d.severity() == powerio_core::DiagnosticSeverity::Warning
2747            }),
2748            "the warning must be present on the returned public module: {:?}",
2749            module.diagnostics()
2750        );
2751        let demand: f64 = module.value().loads().iter().map(|load| load.p).sum();
2752        assert!(
2753            (demand - 12.0).abs() < 1e-12,
2754            "unsupported conversion must not mutate literal values: {demand}"
2755        );
2756        assert_eq!(
2757            emit_text(&module, TargetFormat::Matpower).unwrap().text,
2758            case,
2759            "same-format emission must preserve the exact source bytes"
2760        );
2761    }
2762}