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