Skip to main content

powerio_tx/format/
mod.rs

1//! Readers and writers for supported case formats, all meeting at [`BalancedNetwork`].
2//!
3//! Each format module owns its reader and/or writer: MATPOWER `.m`,
4//! PowerModels JSON, PSS/E `.raw`, PowerWorld `.aux`, egret `ModelData` JSON,
5//! pandapower JSON, PyPSA CSV folders, PSLF `.epc`, GO Challenge 3 JSON, and
6//! Surge JSON, and DeepMind OPFData JSON. PowerWorld `.pwb` cases, GO Challenge
7//! 3 and OPFData JSON canonical output, and PowerWorld `.pwd` displays are read
8//! only. Case input and
9//! output formats meet here, so adding a writable format is one module plus
10//! one hub registration.
11//! [`parse`] reads a retained source into a typed module, detecting the
12//! format from the source name and content; [`parse_display_file`] reads
13//! display artifacts such as PowerWorld `.pwd`. [`write_as`] writes a parsed
14//! module, echoing the retained source on a same format target, and
15//! [`write_network`] is the semantic write for bare typed networks.
16//! Non-finite numeric values, such as MATPOWER `Inf`/`NaN` angle limits, are
17//! written as JSON `null`.
18//!
19//! # Fidelity behavior
20//!
21//! Conversion is two-tier:
22//!
23//! - **Same format writes of an unchanged parsed module return the original
24//!   bytes.** The module retains its source, so [`write_as`] back to the same
25//!   format returns every field, comment, and numeric token.
26//! - **Cross-format keeps maximal fidelity with itemized loss.** Whatever the
27//!   target format cannot represent is reported in the [`Conversion`]
28//!   findings, never dropped silently. On the read side, readers itemize what
29//!   they ignore on the module's diagnostics.
30
31use std::collections::{BTreeSet, HashMap};
32use std::fmt;
33use std::str::FromStr;
34
35use serde_json::{Map, Value};
36
37use powerio_core::{PioModule, SourceDescriptor};
38
39use crate::diagnostics::{Diagnostic, DiagnosticInfo, Diagnostics, EmitFamily, codes};
40use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
41use crate::network::{BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat};
42use crate::{Error, Result};
43use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
44
45mod decode;
46mod egret;
47#[doc(hidden)]
48pub mod goc3;
49mod matpower;
50mod opfdata;
51mod pandapower;
52mod powermodels;
53pub mod powerworld;
54mod pslf;
55mod psse;
56mod pypsa;
57pub mod routing;
58mod surge;
59
60pub use egret::{egret_declares_time_series, parse_egret_time_series, write_egret_json};
61pub use goc3::parse_goc3_json;
62pub use matpower::write_matpower;
63pub use opfdata::{OpfDataSolution, parse_opfdata_json};
64pub use pandapower::write_pandapower_json;
65pub use powermodels::write_powermodels_json;
66pub use powerworld::{PwdDisplay, PwdSubstation, write_powerworld};
67pub use pslf::write_pslf;
68pub use psse::{write_psse, write_psse_rev};
69pub use pypsa::{
70    PypsaAxis, PypsaCsvOutputs, PypsaCsvSequence, parse_pypsa_csv_time_series, pypsa_axis,
71    write_pypsa_csv_folder,
72};
73pub use surge::write_surge_json;
74
75/// A target case format. See [`write_as`].
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum TargetFormat {
79    /// PowerModels.jl network data JSON.
80    PowerModelsJson,
81    /// egret `ModelData` JSON.
82    EgretJson,
83    /// PSS/E `.raw` at the given revision. `rev` selects the record layout the
84    /// writer emits (33, 34, or 35); 33 is the historical default. The reader
85    /// takes the revision from the file header, so this only affects writes.
86    Psse { rev: u32 },
87    /// PowerWorld auxiliary `.aux`.
88    PowerWorld,
89    /// pandapower `pandapowerNet` JSON.
90    PandapowerJson,
91    /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source).
92    Matpower,
93    /// GE PSLF `.epc` (round-trip; byte-exact when the case kept its source).
94    Pslf,
95    /// DOE GO Challenge 3 JSON input data. This is read only except for
96    /// same format source echo when the parsed network still carries its source.
97    Goc3Json,
98    /// Surge native JSON network document.
99    SurgeJson,
100    /// One JSON document from a DeepMind OPFData release. Read only except for
101    /// an exact write back to the retained source format.
102    DeepMindOpfDataJson,
103}
104
105impl TargetFormat {
106    /// Conventional file extension for this format (no leading dot).
107    #[must_use]
108    pub fn extension(self) -> &'static str {
109        match self {
110            TargetFormat::PowerModelsJson
111            | TargetFormat::EgretJson
112            | TargetFormat::PandapowerJson
113            | TargetFormat::Goc3Json
114            | TargetFormat::SurgeJson
115            | TargetFormat::DeepMindOpfDataJson => "json",
116            TargetFormat::Psse { .. } => "raw",
117            TargetFormat::PowerWorld => "aux",
118            TargetFormat::Matpower => "m",
119            TargetFormat::Pslf => "epc",
120        }
121    }
122
123    /// Human-readable format name for diagnostics.
124    #[must_use]
125    pub fn label(self) -> &'static str {
126        match self {
127            TargetFormat::PowerModelsJson => "PowerModels JSON",
128            TargetFormat::EgretJson => "egret JSON",
129            TargetFormat::Psse { .. } => "PSS/E .raw",
130            TargetFormat::PowerWorld => "PowerWorld .aux",
131            TargetFormat::PandapowerJson => "pandapower JSON",
132            TargetFormat::Matpower => "MATPOWER .m",
133            TargetFormat::Pslf => "PSLF .epc",
134            TargetFormat::Goc3Json => "GO Challenge 3 JSON",
135            TargetFormat::SurgeJson => "Surge JSON",
136            TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
137        }
138    }
139
140    /// Canonical API token for this format.
141    #[must_use]
142    pub fn token(self) -> &'static str {
143        match self {
144            TargetFormat::PowerModelsJson => "powermodels-json",
145            TargetFormat::EgretJson => "egret-json",
146            TargetFormat::Psse { rev: 34 } => "psse34",
147            TargetFormat::Psse { rev: 35 } => "psse35",
148            TargetFormat::Psse { .. } => "psse",
149            TargetFormat::PowerWorld => "powerworld",
150            TargetFormat::PandapowerJson => "pandapower-json",
151            TargetFormat::Matpower => "matpower",
152            TargetFormat::Pslf => "pslf",
153            TargetFormat::Goc3Json => "goc3-json",
154            TargetFormat::SurgeJson => "surge-json",
155            TargetFormat::DeepMindOpfDataJson => "opfdata-json",
156        }
157    }
158}
159
160impl fmt::Display for TargetFormat {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.write_str(self.token())
163    }
164}
165
166impl FromStr for TargetFormat {
167    type Err = Error;
168
169    fn from_str(name: &str) -> Result<Self> {
170        target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
171    }
172}
173
174/// A display artifact format. These files are not power network cases and do
175/// not parse to [`BalancedNetwork`].
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum DisplayFormat {
179    /// PowerWorld oneline display `.pwd`.
180    PowerWorld,
181    /// The standalone geographic document ([`crate::geo::GeoLayer`]):
182    /// canonical `.geo.json`, read tolerantly from GeoJSON, aliased CSV/JSON
183    /// records, and headerless buscoords CSV.
184    GeoJson,
185}
186
187impl DisplayFormat {
188    /// Conventional file extension for this display format (no leading dot).
189    #[must_use]
190    pub fn extension(self) -> &'static str {
191        match self {
192            DisplayFormat::PowerWorld => "pwd",
193            DisplayFormat::GeoJson => crate::geo::GEO_LAYER_EXTENSION,
194        }
195    }
196
197    /// Human-readable format name for diagnostics.
198    #[must_use]
199    pub fn label(self) -> &'static str {
200        match self {
201            DisplayFormat::PowerWorld => "PowerWorld .pwd",
202            DisplayFormat::GeoJson => "geo layer",
203        }
204    }
205
206    /// Canonical API token for this format.
207    #[must_use]
208    pub fn token(self) -> &'static str {
209        match self {
210            DisplayFormat::PowerWorld => "powerworld-display",
211            DisplayFormat::GeoJson => "geojson",
212        }
213    }
214}
215
216impl fmt::Display for DisplayFormat {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        f.write_str(self.token())
219    }
220}
221
222impl FromStr for DisplayFormat {
223    type Err = Error;
224
225    fn from_str(name: &str) -> Result<Self> {
226        display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
227    }
228}
229
230/// Map a display format name to a [`DisplayFormat`], or `None` if unrecognized.
231/// Accepts `pwd`, `powerworld-pwd`, and `powerworld-display`; `geojson`,
232/// `geo-json`, and `geo` name the geographic layer.
233#[must_use]
234pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
235    Some(match name.to_ascii_lowercase().as_str() {
236        "pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
237        "geojson" | "geo-json" | "geo" => DisplayFormat::GeoJson,
238        _ => return None,
239    })
240}
241
242/// Map a format name (with the common aliases) to a [`TargetFormat`], or `None`
243/// if unrecognized. Accepts `matpower`/`m`, `powermodels-json`/`powermodels`/`pm`,
244/// `egret-json`/`egret`, `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`,
245/// `powerworld`/`aux`, `pslf`/`epc`, `goc3-json`/`goc3`, and
246/// `surge-json`/`surge`, and `opfdata-json`/`opfdata`/`gridopt`.
247/// Case-insensitive. The one place the bindings (Python, C ABI) share, so a new
248/// text format means one new arm here, not three. PyPSA CSV folders, GridFM
249/// datasets, and PowerWorld `.pwb` are directory or read only inputs with no
250/// text target; they are routed by [`crate::format::routing`].
251///
252/// [`SourceFormat`]'s reported token is [`SourceFormat::name`], which resolves
253/// here directly, so `net.to_format(other.source_format)` works for every
254/// format. The `powermodelsjson`/`egretjson`/`pandapowerjson` aliases keep the
255/// pre-0.9 camel-case spellings (`"PowerModelsJson"` lowercased) resolving for
256/// callers that stored them.
257#[must_use]
258pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
259    Some(match routing::transmission_format_from_name(name)? {
260        TransmissionFormat::Matpower => TargetFormat::Matpower,
261        TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
262        TransmissionFormat::EgretJson => TargetFormat::EgretJson,
263        TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
264        TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
265        TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
266        TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
267        TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
268        TransmissionFormat::Pslf => TargetFormat::Pslf,
269        TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
270        TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
271        TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
272        TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
273            return None;
274        }
275    })
276}
277
278/// Output of a display parse. PowerWorld `.pwd` produces
279/// [`DisplayData::PowerWorld`]; a geographic sidecar produces
280/// [`DisplayData::Geo`].
281#[derive(Debug, Clone, PartialEq)]
282#[non_exhaustive]
283pub enum DisplayData {
284    /// PowerWorld oneline display data.
285    PowerWorld(PwdDisplay),
286    /// A standalone geographic layer.
287    Geo(crate::geo::GeoLayer),
288}
289
290impl DisplayData {
291    /// The display format represented by this value.
292    #[must_use]
293    pub fn format(&self) -> DisplayFormat {
294        match self {
295            DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
296            DisplayData::Geo(_) => DisplayFormat::GeoJson,
297        }
298    }
299}
300
301fn display_file_guidance() -> Error {
302    Error::UnknownFormat(
303        "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
304         use parse_display_file(path, None)"
305            .into(),
306    )
307}
308
309/// Parse display bytes in the named display format `from`.
310///
311/// # Errors
312/// [`Error::UnknownFormat`] if `from` is not a display format; otherwise the
313/// reader's own [`Error`] on malformed input.
314pub fn parse_display_bytes(bytes: &[u8], from: &str) -> Result<DisplayData> {
315    let fmt =
316        display_format_from_name(from).ok_or_else(|| Error::UnknownFormat(from.to_string()))?;
317    match fmt {
318        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
319            bytes,
320        )?)),
321        // The tolerant reader's own notes are available through
322        // `GeoLayer::parse_bytes` for callers that want them.
323        DisplayFormat::GeoJson => Ok(DisplayData::Geo(
324            crate::geo::GeoLayer::parse_bytes(bytes, None)?.layer,
325        )),
326    }
327}
328
329/// Render a file extension for a user-facing message: `` extension `xyz` ``
330/// when present, `no extension` otherwise.
331fn describe_extension(extension: Option<&str>) -> String {
332    match extension {
333        Some(ext) => format!("extension `{ext}`"),
334        None => "no extension".to_owned(),
335    }
336}
337
338/// Parse the display file at `path`, choosing the reader from `from` or, when
339/// `None`, from the extension. A `.pwd` extension selects PowerWorld display
340/// data.
341///
342/// # Errors
343/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension cannot
344/// be mapped; [`Error::Io`] if the file cannot be read; the reader's own
345/// [`Error`] on malformed input.
346pub fn parse_display_file(
347    path: impl AsRef<std::path::Path>,
348    from: Option<&str>,
349) -> Result<DisplayData> {
350    let path = path.as_ref();
351    let fmt = match from {
352        Some(f) => {
353            display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
354        }
355        None => match path
356            .extension()
357            .and_then(|e| e.to_str())
358            .map(str::to_ascii_lowercase)
359            .as_deref()
360        {
361            Some("pwd") => DisplayFormat::PowerWorld,
362            Some("geojson") => DisplayFormat::GeoJson,
363            // `.geo.json` is the canonical layer name; a bare `.json` stays
364            // ambiguous (it is usually a case file).
365            Some("json")
366                if path
367                    .file_name()
368                    .and_then(|name| name.to_str())
369                    .is_some_and(|name| {
370                        name.to_ascii_lowercase()
371                            .ends_with(crate::geo::GEO_LAYER_EXTENSION)
372                    }) =>
373            {
374                DisplayFormat::GeoJson
375            }
376            other => {
377                return Err(Error::UnknownFormat(format!(
378                    "cannot infer display format from file with {}; \
379                     pass an explicit display format",
380                    describe_extension(other)
381                )));
382            }
383        },
384    };
385    let bytes = read_file_bytes(path)?;
386    match fmt {
387        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
388            &bytes,
389        )?)),
390        DisplayFormat::GeoJson => Ok(DisplayData::Geo(
391            crate::geo::GeoLayer::parse_bytes(&bytes, path.file_name().and_then(|n| n.to_str()))?
392                .layer,
393        )),
394    }
395}
396
397/// An I/O failure naming the path it happened on. The bare OS message ("No
398/// such file or directory") reaches callers who cannot see which path the
399/// library resolved, so every read here names the file.
400pub(crate) fn named_io_error(path: &std::path::Path, e: &std::io::Error) -> Error {
401    Error::Io(std::io::Error::new(
402        e.kind(),
403        format!("cannot read {}: {e}", path.display()),
404    ))
405}
406
407fn read_file_bytes(path: &std::path::Path) -> Result<Vec<u8>> {
408    std::fs::read(path).map_err(|e| named_io_error(path, &e))
409}
410
411/// Whether a format name means a PyPSA CSV folder. PyPSA folders are directory
412/// inputs, not text targets, so they have no [`TargetFormat`] arm; this is the
413/// companion alias matcher to [`target_format_from_name`] and the one place the
414/// PyPSA aliases live.
415pub fn is_pypsa_csv_name(name: &str) -> bool {
416    matches!(
417        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
418        "pypsacsv" | "pypsa"
419    )
420}
421
422/// Whether a source format name means PSLF EPC.
423fn is_pslf_name(name: &str) -> bool {
424    matches!(
425        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
426        "pslf" | "epc" | "pslfepc"
427    )
428}
429
430/// Parse the case file at `path`, choosing the reader from `from` (the
431/// [`target_format_from_name`] names plus `pypsa-csv`/`pypsa`, `pwb`, `pslf`,
432/// and `epc`) or, when `None`, from the path: a directory containing
433/// `network.csv` parses as a PyPSA CSV folder (any other directory is refused
434/// as a directory with [`Error::UnknownFormat`], before extension inference),
435/// and a file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`/`epc`),
436/// case insensitively (issue #97: `.RAW` is as common as `.raw` in the wild). A
437/// `.json` file is classified by top level shape markers: pandapower
438/// (`"_class": "pandapowerNet"`), egret (`elements` and `system`), GO Challenge
439/// 3 (`network` plus `time_series_input`/`reliability`), Surge JSON
440/// (`format: "surge-json"`), OPFData (`grid`, `solution`, and `metadata`), and
441/// PowerModels JSON (`baseMVA`, `branch`, `gen`, or `gencost`). JSON matching
442/// model JSON markers (`buses` plus a network key), distribution markers,
443/// ambiguous markers, or no known markers returns [`Error::UnknownFormat`].
444/// Declare a format on the source to force a parser. PowerWorld `.pwb` is a
445/// binary read only format; PSLF `.epc` is text and has a writer. Returns the
446/// typed module: the network value, the reader's findings, and the retained
447/// source.
448///
449/// The one parser the CLI and the Python/C/Julia bindings share, so adding a
450/// source format is one edit here, not one per binding.
451///
452/// # Errors
453/// A `Request` failure when the format cannot be determined or is refused, an
454/// `Io` failure when acquisition fails, and the reader's own failure on
455/// malformed input. Findings collected before a failure ride the returned
456/// error.
457///
458pub fn parse(
459    source: powerio_core::Source,
460) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
461    parse_with_json_class(source, None)
462}
463
464/// [`parse`], given a JSON classification the caller already computed on the
465/// same bytes. The `powerio` facade routes a source by its own call to
466/// [`routing::classify_json_text`] before it ever reaches this crate; when
467/// that routing lands on the balanced hub, passing the result here skips the
468/// second classification [`parse`] would otherwise run over the identical
469/// text. `None` reproduces [`parse`] exactly, classifying inline only if and
470/// when [`parse_to_network`] needs to.
471///
472/// Not part of this crate's public reading surface — the facade is the one
473/// caller with a classification already in hand — so this stays out of the
474/// rendered docs.
475///
476/// # Errors
477/// Same as [`parse`].
478#[doc(hidden)]
479pub fn parse_with_json_class(
480    source: powerio_core::Source,
481    json_class: Option<routing::JsonClass>,
482) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
483    let mut warnings = Diagnostics::new();
484    match parse_to_network(&source, &mut warnings, json_class) {
485        Ok(network) => {
486            let format = network.source_format();
487            let mut module = PioModule::new(network);
488            for buffer in source.acquired_buffers() {
489                // A stored descriptor is a display name, not a filesystem
490                // path; keep only the final component of a buffer name that
491                // came from Source::open.
492                let name = std::path::Path::new(buffer.name())
493                    .file_name()
494                    .and_then(|name| name.to_str())
495                    .unwrap_or_else(|| buffer.name());
496                let descriptor = match SourceDescriptor::new(
497                    buffer.id().clone(),
498                    name,
499                    buffer.bytes().len() as u64,
500                ) {
501                    Ok(descriptor) => descriptor,
502                    Err(error) => return Err(error.with_source(source)),
503                };
504                // SourceFormat::name is always a valid format id; skipping
505                // on the impossible error keeps this path panic free.
506                let descriptor = match powerio_core::FormatId::new(format.name()) {
507                    Ok(id) => descriptor.with_format(id),
508                    Err(_) => descriptor,
509                };
510                if let Err(error) = module.add_source_descriptor(descriptor) {
511                    return Err(error.with_source(source));
512                }
513            }
514            let mut module = module.with_source(source);
515            for record in warnings.into_records() {
516                module.add_diagnostic(record)?;
517            }
518            Ok(module)
519        }
520        Err(error) => {
521            let core = powerio_core::Error::new(error.code(), error.to_string());
522            Err(core
523                .with_diagnostics(warnings.into_records())
524                .with_cause(error)
525                .with_source(source))
526        }
527    }
528}
529
530/// The format dispatch behind [`parse`]: name and content detection, then the
531/// one reader map. `json_class` is a classification the caller already
532/// computed on this source's own text ([`parse_with_json_class`]); when it is
533/// `None`, this classifies inline at the point a `.json` source needs it,
534/// exactly as [`parse`] always has.
535fn parse_to_network(
536    source: &powerio_core::Source,
537    warnings: &mut Diagnostics,
538    json_class: Option<routing::JsonClass>,
539) -> Result<BalancedNetwork> {
540    let from = source.format().map(powerio_core::FormatId::as_str);
541    let path = std::path::Path::new(source.name());
542    // The file stem is the name hint for formats that don't carry their own
543    // name. An angle bracketed source name is the conventional non-file
544    // spelling an anonymous in-memory caller uses and carries no hint; a name
545    // with an extension contributes its stem, and any other name is the hint
546    // itself.
547    let stem = if source.name().starts_with('<') {
548        None
549    } else if path.extension().is_some() {
550        path.file_stem().and_then(|stem| stem.to_str())
551    } else {
552        Some(source.name())
553    };
554    // PyPSA CSV folders are directories, not files; dispatch them before any
555    // extension logic. `from` accepts the pypsa aliases, and a bare directory
556    // source with a `network.csv` auto-detects.
557    if source.is_directory() {
558        let marker = powerio_core::ArtifactPath::new("network.csv")
559            .expect("static name is a valid artifact path");
560        if from.is_some_and(is_pypsa_csv_name) || (from.is_none() && source.buffer(&marker).is_ok())
561        {
562            return pypsa::read_pypsa_csv_source(source, warnings);
563        }
564        // Any other directory has no reader; refuse it as a directory before
565        // the extension logic reads ".07" off a name like `pglib-opf-23.07`.
566        return Err(Error::UnknownFormat(format!(
567            "{} is a directory, and the only directory case format is a PyPSA CSV \
568             folder (one holding a network.csv); pass a case file",
569            path.display()
570        )));
571    }
572    if from.is_some_and(is_pypsa_csv_name) {
573        return Err(Error::UnknownFormat(
574            "a PyPSA CSV case is a directory holding a network.csv; open the folder as the source"
575                .into(),
576        ));
577    }
578    // PowerWorld `.pwb` is binary and read only; dispatch it before the text
579    // read. `from` accepts "pwb" for files with a different extension.
580    let ext = path
581        .extension()
582        .and_then(|e| e.to_str())
583        .map(str::to_ascii_lowercase);
584    if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
585        || (from.is_none() && ext.as_deref() == Some("pwb"))
586    {
587        // Binary input: the exact bytes go to the reader, byte order mark
588        // handling included, since the mark is a text concept.
589        let buffer = primary(source)?;
590        return powerworld::parse_pwb_collecting(buffer.bytes(), stem, warnings);
591    }
592    if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
593        let buffer = primary(source)?;
594        let network = pslf::parse_pslf_source(source_text(&buffer)?, stem, warnings)?;
595        reject_empty_case(&network, "PSLF .epc")?;
596        return Ok(network);
597    }
598    if from
599        .and_then(target_format_from_name)
600        .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
601        && matches!(ext.as_deref(), Some("pt" | "gz"))
602    {
603        return Err(Error::UnknownFormat(
604            "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
605                .into(),
606        ));
607    }
608    // Settle the format before touching the file: an unmapped or binary
609    // extension must surface as UnknownFormat, not as the UTF-8 read error
610    // the text formats' loader would hit first. `.pwd` gets its own arm
611    // because the display sibling ships next to every case file in the wild
612    // and carries no case data.
613    if from.is_none() && ext.as_deref() == Some("pwd") {
614        return Err(display_file_guidance());
615    }
616    let fmt_hint = match from {
617        Some(f) => {
618            if display_format_from_name(f).is_some() {
619                return Err(display_file_guidance());
620            }
621            Some(target_format_from_name(f).ok_or_else(|| unknown_source_format(f))?)
622        }
623        None => {
624            // Everything but `.json` (sniffed below) resolves without the text.
625            match ext.as_deref() {
626                Some("m") => Some(TargetFormat::Matpower),
627                Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
628                Some("aux") => Some(TargetFormat::PowerWorld),
629                Some("json") => None,
630                Some("dss") => return Err(unknown_source_format("dss")),
631                other => {
632                    // A nameless or oddly named source can still carry a JSON
633                    // document (in-memory text has no extension to state);
634                    // sniff it like a `.json` before refusing. The primary
635                    // buffer is already retained, so peeking is free.
636                    let jsonish = source.primary_buffer().is_ok_and(|buffer| {
637                        source_text(&buffer)
638                            .is_ok_and(|text| text.trim_start().starts_with(['{', '[']))
639                    });
640                    if jsonish {
641                        None
642                    } else {
643                        return Err(Error::UnknownFormat(format!(
644                            "cannot infer from source name with {}; \
645                             declare a source format",
646                            describe_extension(other)
647                        )));
648                    }
649                }
650            }
651        }
652    };
653    // The parser decodes a byte order mark free slice of the one retained
654    // buffer; the module keeps the exact original bytes for same format
655    // writing. Sniffing a `.json` borrows the same slice.
656    let buffer = primary(source)?;
657    let text = source_text(&buffer)?;
658    let fmt = match fmt_hint {
659        Some(fmt) => fmt,
660        // A caller ahead of this (the `powerio` facade's own routing) may
661        // already have classified this exact text; trust that answer instead
662        // of running the same classification a second time. `unwrap_or_else`
663        // only classifies here when nothing did yet, so a caller with no
664        // hint (every direct `parse` caller) behaves exactly as before.
665        None => match json_class.unwrap_or_else(|| routing::classify_json_text(text)) {
666            // The network serialization is not a case format, but it parses:
667            // a bare model JSON document decodes through `from_json` and
668            // routes to `BalancedNetwork` like any other balanced source.
669            JsonClass::ModelJson => return BalancedNetwork::from_json(text),
670            class => json_target_from_class(class)?,
671        },
672    };
673    read_source(text, fmt, stem, warnings)
674}
675
676/// The primary buffer of a file or memory source.
677fn primary(source: &powerio_core::Source) -> Result<powerio_core::SourceBuffer> {
678    source.primary_buffer().map_err(|error| Error::FormatRead {
679        format: "source",
680        message: error.to_string(),
681    })
682}
683
684/// The text a reader decodes: the buffer's byte order mark free slice,
685/// validated as UTF-8.
686fn source_text(buffer: &powerio_core::SourceBuffer) -> Result<&str> {
687    std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
688        format: "case text",
689        message: format!("not valid UTF-8: {e}"),
690    })
691}
692
693/// Read decoded `text` as `fmt`, using `name_hint` (e.g. the file stem) when
694/// the format carries no name of its own. The single format to reader map:
695/// every parse route funnels through it, so every format is dispatched the
696/// same way. Readers borrow the text; the module retains the source bytes.
697fn read_source(
698    text: &str,
699    fmt: TargetFormat,
700    name_hint: Option<&str>,
701    warnings: &mut Diagnostics,
702) -> Result<BalancedNetwork> {
703    let net = match fmt {
704        TargetFormat::Matpower => matpower::parse_matpower_source(text, name_hint),
705        TargetFormat::PowerModelsJson => {
706            powermodels::parse_powermodels_json_source(text, name_hint, warnings)
707        }
708        TargetFormat::Psse { .. } => psse::parse_psse_source(text, name_hint, warnings),
709        TargetFormat::PowerWorld => {
710            powerworld::map::parse_powerworld_source(text, name_hint, warnings)
711        }
712        TargetFormat::EgretJson => egret::parse_egret_source(text, name_hint),
713        TargetFormat::PandapowerJson => {
714            pandapower::parse_pandapower_source(text, name_hint, warnings)
715        }
716        // PSLF read normally enters through the `is_pslf_name`/`.epc` fast
717        // path in the dispatch; this arm keeps the funnel total.
718        TargetFormat::Pslf => pslf::parse_pslf_source(text, name_hint, warnings),
719        // The general parse takes the network and drops the typed problem
720        // document; the calculation instance this source declares arrives
721        // with the instance types.
722        TargetFormat::Goc3Json => {
723            goc3::parse_goc3_source(text, name_hint, warnings).map(|(net, _goc3)| net)
724        }
725        TargetFormat::SurgeJson => surge::parse_surge_source(text, name_hint, warnings),
726        TargetFormat::DeepMindOpfDataJson => {
727            opfdata::parse_opfdata_source(text, name_hint, warnings)
728        }
729    }?;
730    reject_empty_case(&net, fmt.label())?;
731    Ok(net)
732}
733
734/// Geographic metadata for a reader that harvested longitude/latitude
735/// coordinates: `Some` once any bus carries a location, so a case without
736/// coordinates serializes exactly as before. The space is stamped geographic
737/// only when every point fits longitude/latitude bounds; a source that
738/// violates its format's own convention (projected meters in a pandapower
739/// `geo` column) reads as unknown instead of claiming WGS84.
740pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
741    let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
742    located.peek()?;
743    let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
744    Some(crate::geo::GeoMeta {
745        space: if in_bounds {
746            crate::geo::CoordinateSpace::Geographic { crs: None }
747        } else {
748            crate::geo::CoordinateSpace::Unknown
749        },
750        kind: None,
751    })
752}
753
754/// A source id from an f64: an in range value truncates the way the readers
755/// always have; a negative, non-finite, or over-ceiling value is refused with
756/// a message naming `column`, instead of letting the `as usize` cast saturate.
757/// The ceiling is [`crate::network::BusId::MAX`] (`i64::MAX`, the C ABI id
758/// bound), applied to every id column so a non-bus id gets the same policy.
759#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
760pub(crate) fn id_from_f64(
761    value: f64,
762    column: impl std::fmt::Display,
763) -> std::result::Result<usize, String> {
764    // Strict `<`: `i64::MAX as f64` rounds up to 2^63, so `<=` would admit
765    // values the cast saturates past `BusId::MAX`.
766    if value >= 0.0 && value < i64::MAX as f64 {
767        Ok(value as usize)
768    } else {
769        // Debug keeps the shortest float form ("1e300", never 301 digits).
770        Err(format!(
771            "`{column}` value {value:?} is outside the id range 0..2^63"
772        ))
773    }
774}
775
776/// A case with no buses is content-free for every consumer. Most readers
777/// already reject it on a missing required table, but a JSON carrying only
778/// `baseMVA` would otherwise parse to a hollow network; reject it in the
779/// [`read_source`] funnel so every parse path (file and in-memory) is guarded,
780/// and in the PyPSA folder reader, which bypasses the funnel.
781pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
782    if net.buses().is_empty() {
783        return Err(Error::FormatRead {
784            format,
785            message: "case has no buses".into(),
786        });
787    }
788    Ok(())
789}
790
791/// The source format names [`parse`] accepts as a declared format, each with
792/// its aliases. The unknown format error prints this list, and a test walks
793/// every alias through [`routing::transmission_format_from_name`] so it
794/// cannot drift from the matcher. `pypsa-csv` names a directory source and
795/// `pwb` a binary one; every other name reads file and memory sources alike.
796pub const SOURCE_FORMAT_NAMES: &str = "matpower/m, powermodels-json/powermodels/pm, \
797     egret-json/egret, psse/raw, psse34, psse35, powerworld/aux, \
798     pandapower-json/pandapower/pp, pslf/epc, pypsa-csv/pypsa, pwb, goc3-json/goc3, \
799     surge-json/surge, opfdata-json/opfdata/gridopt";
800
801/// An unrecognized source format token. When the token names a distribution
802/// format (`dss`, `pmd`, `bmopf`), the error points at the distribution
803/// surface instead of echoing the token: this parser reads only balanced
804/// transmission formats. Otherwise the refusal enumerates the accepted names.
805fn unknown_source_format(name: &str) -> Error {
806    if name.eq_ignore_ascii_case("powerio-json") {
807        return Error::UnknownFormat(
808            "the `powerio-json` token was retired in 0.9.0: model JSON is not a case \
809             format or a conversion target; write it with `to_json` \
810             (`pio_balanced_network_to_json` in C, `json_format model-json` on the MCP \
811             server), store the case as `.pio.json`, and classify a JSON document with \
812             `classify_json_text` (family `model-json`)"
813                .into(),
814        );
815    }
816    if let Some(dist) = routing::distribution_format_from_name(name) {
817        return Error::UnknownFormat(format!(
818            "`{}` is a distribution format, and this parser reads only balanced \
819             transmission formats; parse it through the one module family \
820             (powerio::parse in Rust, pio_parse_file in C, powerio.parse in Python, \
821             parse_file in Julia), which routes distribution formats",
822            dist.name()
823        ));
824    }
825    Error::UnknownFormat(format!("{name}; accepted names: {SOURCE_FORMAT_NAMES}"))
826}
827
828/// The JSON formats share the `.json` extension, so an explicit source format
829/// isn't always given. Classification lives here so the CLI and bindings use
830/// the same top level markers as the Rust parsers.
831#[cfg(test)]
832fn sniff_json(text: &str) -> Result<TargetFormat> {
833    json_target_from_class(routing::classify_json_text(text))
834}
835
836/// The case format a JSON classification selects; the shapes that are not
837/// case formats are refused with the surface that reads them named. Model
838/// JSON never reaches this from `parse`, which decodes it directly.
839fn json_target_from_class(class: JsonClass) -> Result<TargetFormat> {
840    match class {
841        JsonClass::Module => Err(Error::UnknownFormat(
842            "JSON is a .pio.json stored module; read it with the module surface \
843             (powerio::parse in Rust, pio_parse_str in C, powerio.parse in \
844             Python, parse_bytes in Julia)"
845                .into(),
846        )),
847        JsonClass::ModelJson => Err(Error::UnknownFormat(
848            "JSON is bare powerio model JSON, which is not a case format; read it with \
849             BalancedNetwork::from_json (pio_balanced_network_from_json in C, \
850             powerio.from_json in Python, from_json in Julia)"
851                .into(),
852        )),
853        JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
854            transmission_json_target(format)
855        }
856        JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
857            Err(Error::UnknownFormat(format!(
858                "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
859                format.name()
860            )))
861        }
862        JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
863            "ambiguous JSON markers; pass an explicit source format".into(),
864        )),
865        JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
866            "cannot infer JSON format; pass an explicit source format".into(),
867        )),
868    }
869}
870
871fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
872    match format {
873        TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
874        TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
875        TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
876        TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
877        TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
878        TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
879        other => Err(Error::UnknownFormat(format!(
880            "JSON classifier returned non-JSON transmission format `{}`",
881            other.name()
882        ))),
883    }
884}
885
886/// Output of a conversion: the serialized text plus the fidelity findings:
887/// data the target can't represent, defaults synthesized, or blocks mapped
888/// best effort. Empty `diagnostics` means a faithful conversion. For
889/// [`convert_file`] and [`convert_str`], `diagnostics` carries the read side
890/// findings ahead of the write side. Warning is one diagnostic severity;
891/// rendered text lines come from [`crate::diagnostics::render_diagnostics`].
892///
893/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
894/// never constructs it, leaving room to add fidelity metadata without a breaking
895/// change.
896#[derive(Debug, Clone)]
897#[non_exhaustive]
898pub struct Conversion {
899    pub text: String,
900    /// The findings as structured records: a stable code, a severity, and a
901    /// message.
902    pub diagnostics: Vec<Diagnostic>,
903}
904
905impl Conversion {
906    pub(crate) fn new(text: String, diagnostics: Diagnostics) -> Self {
907        Self {
908            text,
909            diagnostics: diagnostics.into_records(),
910        }
911    }
912
913    /// A conversion that dropped nothing, e.g. a same-format echo.
914    pub(crate) fn faithful(text: String) -> Self {
915        Self::new(text, Diagnostics::new())
916    }
917
918    /// The findings as `CODE: message` lines, rendered on request. Warning is
919    /// one diagnostic severity; there is no separately stored text channel.
920    #[must_use]
921    pub fn rendered_diagnostics(&self) -> Vec<String> {
922        crate::diagnostics::render_diagnostics(&self.diagnostics)
923    }
924
925    /// Record one finding after the writer has run.
926    pub(crate) fn push(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
927        self.diagnostics.push(Diagnostic::of(info, message));
928    }
929
930    /// Put the read side's findings ahead of the write side's.
931    pub(crate) fn prepend(&mut self, read: Vec<Diagnostic>) {
932        let mut records = read;
933        records.append(&mut self.diagnostics);
934        self.diagnostics = records;
935    }
936}
937
938/// Optional write-time policies layered on top of the neutral [`BalancedNetwork`].
939///
940/// The default is a no-op and preserves the old `write_as` / `convert_*`
941/// behavior. Non-default options work on a cloned network and never mutate the
942/// caller's case.
943#[derive(Debug, Clone, Default)]
944pub struct WriteOptions {
945    pub missing_gen_cost: MissingGenCostPolicy,
946    pub gen_cost_patches: Vec<GenCostPatch>,
947}
948
949impl WriteOptions {
950    #[must_use]
951    pub fn is_default(&self) -> bool {
952        self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
953    }
954}
955
956/// Write a parsed module to `format`. Writing back to the source format of an
957/// unchanged parsed module returns the retained source bytes exactly,
958/// including a byte order mark; any other target serializes the typed value.
959///
960/// # Errors
961/// [`Error::WriteUnsupported`] for a read only target, and the writer's own
962/// [`Error`] on a case it cannot state.
963pub fn write_as(
964    module: &PioModule<BalancedNetwork>,
965    format: TargetFormat,
966) -> std::result::Result<Conversion, powerio_core::Error> {
967    if let Some(text) = echo_text(module, format) {
968        return Ok(Conversion::faithful(text));
969    }
970    let mut conv = write_conversion(module.value(), format).map_err(core_error)?;
971    warn_psse_downgrade(module, format, &mut conv);
972    Ok(conv)
973}
974
975/// Project a crate failure onto the common operation failure type.
976pub(crate) fn core_error(error: Error) -> powerio_core::Error {
977    let message = error.to_string();
978    powerio_core::Error::new(error.code(), message).with_cause(error)
979}
980
981/// The retained source text when writing `module` back to its source format:
982/// the echo that reproduces the input byte for byte. `None` sends the write
983/// down the semantic path.
984fn echo_text(module: &PioModule<BalancedNetwork>, target: TargetFormat) -> Option<String> {
985    let source = module.source()?;
986    let buffer = source.primary_buffer().ok()?;
987    if !same_format(target, module.value().source_format()) {
988        return None;
989    }
990    let text = std::str::from_utf8(buffer.bytes()).ok()?;
991    // A PSS/E source echoes only when the requested revision equals the
992    // source's own; any other revision goes through write_psse_rev so the
993    // caller gets the layout it asked for instead of the original bytes.
994    if let TargetFormat::Psse { rev } = target
995        && psse::header_rev(text.trim_start_matches('\u{feff}')) != rev
996    {
997        return None;
998    }
999    Some(text.to_owned())
1000}
1001
1002/// Serialize a typed network to `format` with no source echo: the semantic
1003/// write used for values constructed in memory or severed from their module.
1004///
1005/// # Errors
1006/// As [`write_as`].
1007pub fn write_network(
1008    net: &BalancedNetwork,
1009    format: TargetFormat,
1010) -> std::result::Result<Conversion, powerio_core::Error> {
1011    write_conversion(net, format).map_err(core_error)
1012}
1013
1014pub(crate) fn write_conversion(net: &BalancedNetwork, format: TargetFormat) -> Result<Conversion> {
1015    let mut conv = match format {
1016        TargetFormat::PowerModelsJson => write_powermodels_json(net),
1017        TargetFormat::EgretJson => write_egret_json(net),
1018        TargetFormat::Psse { rev } => write_psse_rev(net, rev),
1019        TargetFormat::PowerWorld => write_powerworld(net),
1020        TargetFormat::PandapowerJson => write_pandapower_json(net),
1021        // From another source (or no retained source): canonical MATPOWER from
1022        // the folded model, which itemizes what it can't carry (HVDC, gen caps,
1023        // extras, a partial-cost case).
1024        TargetFormat::Matpower => matpower::write_matpower_conversion(net),
1025        TargetFormat::Pslf => write_pslf(net),
1026        TargetFormat::SurgeJson => write_surge_json(net),
1027        TargetFormat::Goc3Json => {
1028            return Err(Error::WriteUnsupported {
1029                format: "goc3-json",
1030            });
1031        }
1032        TargetFormat::DeepMindOpfDataJson => {
1033            return Err(Error::WriteUnsupported {
1034                format: "opfdata-json",
1035            });
1036        }
1037    };
1038    warn_normalized_tap(net, format, &mut conv);
1039    warn_missing_reference(net, format, &mut conv);
1040    warn_dropped_frequency(net, format, &mut conv);
1041    warn_dropped_locations(net, format, &mut conv);
1042    warn_dropped_transformer_charging(net, format, &mut conv);
1043    Ok(conv)
1044}
1045
1046/// Write a parsed module to `format` through a destination: the one write
1047/// operation over file, memory, and (for the directory formats) folder
1048/// output. Every text target commits a single artifact — a path destination
1049/// names the exact file, a memory destination names the artifact — staged
1050/// and renamed into place so a failed write never exposes a partial target.
1051/// The result carries the complete artifact inventory and the writer's
1052/// findings. PyPSA CSV folders write through [`write_pypsa_csv`].
1053///
1054/// # Errors
1055/// As [`write_as`], plus the destination's own collision and staging
1056/// failures.
1057pub fn write(
1058    module: &PioModule<BalancedNetwork>,
1059    format: TargetFormat,
1060    destination: powerio_core::Destination,
1061) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1062    write_with_options(module, format, &WriteOptions::default(), destination)
1063}
1064
1065/// [`write()`] with write-time cost policies, as [`write_as_with_options`].
1066///
1067/// # Errors
1068/// As [`write()`].
1069///
1070/// # Panics
1071/// Never on external input: the fixed artifact name is valid by
1072/// construction.
1073pub fn write_with_options(
1074    module: &PioModule<BalancedNetwork>,
1075    format: TargetFormat,
1076    options: &WriteOptions,
1077    destination: powerio_core::Destination,
1078) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1079    let conv = write_as_with_options(module, format, options)?;
1080    let artifact = powerio_core::MemoryArtifact::new(
1081        powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1082        conv.text.into_bytes(),
1083    );
1084    destination.__commit_artifacts(false, vec![artifact], conv.diagnostics)
1085}
1086
1087/// Write a parsed module as a PyPSA CSV folder through a destination: the
1088/// directory form of [`write()`]. Either destination names the output root and
1089/// every returned artifact sits below it; the whole inventory commits
1090/// atomically.
1091///
1092/// # Errors
1093/// The destination's collision and staging failures.
1094///
1095/// # Panics
1096/// Never on external input: the writer's fixed artifact names are valid by
1097/// construction.
1098pub fn write_pypsa_csv(
1099    module: &PioModule<BalancedNetwork>,
1100    destination: powerio_core::Destination,
1101) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1102    let (artifacts, diagnostics) = pypsa::pypsa_csv_artifacts(module.value());
1103    let artifacts = artifacts
1104        .into_iter()
1105        .map(|(name, text)| {
1106            powerio_core::MemoryArtifact::new(
1107                powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
1108                text.into_bytes(),
1109            )
1110        })
1111        .collect();
1112    destination.__commit_artifacts(true, artifacts, diagnostics)
1113}
1114
1115/// Write a parsed module with write-time cost policies. The plain
1116/// [`write_as`] behavior is preserved when `options` is default; a non-default
1117/// policy edits a copy of the typed value, so its write never echoes source
1118/// bytes the policy no longer matches.
1119pub fn write_as_with_options(
1120    module: &PioModule<BalancedNetwork>,
1121    format: TargetFormat,
1122    options: &WriteOptions,
1123) -> std::result::Result<Conversion, powerio_core::Error> {
1124    if options.is_default() {
1125        return write_as(module, format);
1126    }
1127    let (working, policy_warnings) =
1128        apply_write_cost_policy(module.value(), options).map_err(core_error)?;
1129    let mut conv = write_conversion(&working, format).map_err(core_error)?;
1130    conv.prepend(policy_warnings);
1131    Ok(conv)
1132}
1133
1134/// Apply the write-time cost policy to a copy of `net` and report what it did.
1135///
1136/// Shared by the text and directory writers so both surfaces run one policy and
1137/// describe it with the same findings. The caller's network is never mutated.
1138pub(crate) fn apply_write_cost_policy(
1139    net: &BalancedNetwork,
1140    options: &WriteOptions,
1141) -> Result<(BalancedNetwork, Vec<Diagnostic>)> {
1142    let mut working = net.clone();
1143    let report =
1144        working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
1145    let mut policy_warnings = Diagnostics::new();
1146    if report.patched > 0 {
1147        policy_warnings.push(
1148            &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1149            format!(
1150                "generator cost patch applied to {} generator(s)",
1151                report.patched
1152            ),
1153        );
1154    }
1155    if report.synthesized > 0 {
1156        policy_warnings.push(
1157            &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1158            match options.missing_gen_cost {
1159                MissingGenCostPolicy::Fill {
1160                    c2,
1161                    c1,
1162                    c0,
1163                    startup,
1164                    shutdown,
1165                } => format!(
1166                    "generator cost synthesized for {} generator(s): model 2, ncost 3, \
1167                 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
1168                    report.synthesized
1169                ),
1170                _ => unreachable!("only Fill synthesizes costs"),
1171            },
1172        );
1173    }
1174    Ok((working, policy_warnings.into_records()))
1175}
1176
1177/// Allocate a circuit id for an element keyed by `key` — a bus for loads/shunts,
1178/// or a `(from, to)` pair for branches: reuse the source-supplied `preferred` id
1179/// when it is still free on this key, else the lowest free positional id. Keeps
1180/// parallel devices distinct so the `(key, id)` uniqueness rule the PSS/E and
1181/// PSLF records require holds even when the source supplies colliding ids.
1182pub(super) fn allocate_circuit_id<K: Ord + Clone>(
1183    preferred: Option<&str>,
1184    key: K,
1185    used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
1186) -> String {
1187    let taken = used.entry(key).or_default();
1188    if let Some(id) = preferred {
1189        if taken.insert(id.to_owned()) {
1190            return id.to_owned();
1191        }
1192    }
1193    let mut n = 1u32;
1194    loop {
1195        let candidate = n.to_string();
1196        if taken.insert(candidate.clone()) {
1197            return candidate;
1198        }
1199        n += 1;
1200    }
1201}
1202
1203/// Warn when a PSS/E source is re-serialized at an older revision than its own.
1204/// `parse_file` maps every `.raw` to revision 33 and the `psse`/`raw` aliases
1205/// resolve to 33, so writing a v34/v35 source through the default target skips
1206/// the echo path (revisions differ) and re-emits the v33 layout, dropping the
1207/// modern records (12 named ratings, load DG/LOADTYPE columns, the system-wide
1208/// block) and any unmodeled section the echo would have preserved. Name the
1209/// downgrade instead of performing it silently.
1210fn warn_psse_downgrade(
1211    module: &PioModule<BalancedNetwork>,
1212    format: TargetFormat,
1213    conv: &mut Conversion,
1214) {
1215    let source_text = module
1216        .source()
1217        .and_then(|source| source.primary_buffer().ok())
1218        .and_then(|buffer| String::from_utf8(buffer.content_bytes().to_vec()).ok());
1219    if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) = (
1220        format,
1221        module.value().source_format(),
1222        source_text.as_deref(),
1223    ) {
1224        let src_rev = psse::header_rev(src);
1225        if src_rev > rev {
1226            conv.push(
1227                &codes::EMIT_PSSE_DOWNGRADED,
1228                format!(
1229                    "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
1230                     the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
1231                ),
1232            );
1233        }
1234    }
1235}
1236
1237/// Warn when a non-default system frequency writes to a format with no frequency
1238/// field. PSS/E (`BASFRQ`) and pandapower (`f_hz`) carry it; MATPOWER,
1239/// PowerModels, egret, and PowerWorld have nowhere to put it, so a 50 Hz case
1240/// would silently read back as the 60 Hz default. Report the loss instead.
1241fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1242    let carries_frequency = matches!(
1243        format,
1244        TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
1245    );
1246    if carries_frequency {
1247        return;
1248    }
1249    if (net.base_frequency() - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1250        conv.push(
1251            &format.emit_family().field_dropped,
1252            format!(
1253                "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1254                net.base_frequency(),
1255                format.label(),
1256                crate::network::DEFAULT_BASE_FREQUENCY
1257            ),
1258        );
1259    }
1260}
1261
1262/// Warn when the case carries bus locations and the target has no geometry
1263/// concept. PowerWorld aux (`Latitude:1`/`Longitude:1`) and pandapower
1264/// (`geo`) carry them, and the PyPSA folder writer (`x`/`y`) has its own
1265/// path; MATPOWER, PSS/E, PowerModels, egret, PSLF, and Surge have nowhere to
1266/// put them, matching the `base_frequency` behavior. `powerio geo extract`
1267/// writes the sidecar as the escape hatch.
1268fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1269    let carries_locations = matches!(
1270        format,
1271        TargetFormat::PowerWorld | TargetFormat::PandapowerJson
1272    );
1273    if carries_locations {
1274        return;
1275    }
1276    let n = net.buses().iter().filter(|b| b.location.is_some()).count();
1277    let routed = net.branches().iter().filter(|b| b.route.is_some()).count();
1278    if n > 0 || routed > 0 {
1279        conv.push(
1280            &format.emit_family().field_dropped,
1281            format!(
1282                "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1283                 coordinate field (write a .geo.json sidecar to keep them)",
1284                format.label()
1285            ),
1286        );
1287    }
1288}
1289
1290/// Warn when a transformer carries line charging and the target's
1291/// transformer record has no susceptance column to hold it. The PSLF `.epc`
1292/// transformer record is the one such target; PSS/E writes representable
1293/// magnetizing admittance and the MATPOWER shaped writers keep the legacy total
1294/// projection on the branch row, so neither drops it.
1295fn warn_dropped_transformer_charging(
1296    net: &BalancedNetwork,
1297    format: TargetFormat,
1298    conv: &mut Conversion,
1299) {
1300    if !matches!(format, TargetFormat::Pslf) {
1301        return;
1302    }
1303    let n = net
1304        .branches()
1305        .iter()
1306        .filter(|b| b.is_transformer() && b.total_charging_b() != 0.0)
1307        .count();
1308    if n > 0 {
1309        conv.push(
1310            &codes::EMIT_PSLF.field_dropped,
1311            format!(
1312                "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1313                 record cannot represent; the charging was dropped"
1314            ),
1315        );
1316    }
1317}
1318
1319pub(super) fn branch_rating_set_drop_warning(
1320    target: &str,
1321    branch_index: usize,
1322    branch: &Branch,
1323    rating: &BranchRatingSet,
1324) -> String {
1325    format!(
1326        "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1327        branch_index + 1,
1328        branch.from,
1329        branch.to,
1330        rating.name,
1331        rating.rate_mva,
1332        target
1333    )
1334}
1335
1336/// Warn once when elements carry passthrough extras `target`'s writer does not
1337/// replay. `consumed` is the writer's own rule: the keys it reads back into a
1338/// record. Everything else was retained by a reader because the source stated
1339/// more than a rewrite would synthesize, so dropping it without saying so is
1340/// an undeclared loss (#330). One line, a count and the reason, matching the
1341/// granularity of the other writer warnings.
1342pub(super) fn warn_dropped_extras(
1343    family: &'static EmitFamily,
1344    target: &str,
1345    net: &BalancedNetwork,
1346    consumed: impl Fn(&str) -> bool,
1347    warnings: &mut Diagnostics,
1348) {
1349    let carries = |extras: &crate::network::Extras| extras.keys().any(|k| !consumed(k));
1350    let dropped = net.buses().iter().filter(|e| carries(&e.extras)).count()
1351        + net.branches().iter().filter(|e| carries(&e.extras)).count()
1352        + net.loads().iter().filter(|e| carries(&e.extras)).count()
1353        + net.shunts().iter().filter(|e| carries(&e.extras)).count()
1354        + net.switches().iter().filter(|e| carries(&e.extras)).count()
1355        + net.storage().iter().filter(|e| carries(&e.extras)).count()
1356        + net.hvdc().iter().filter(|e| carries(&e.extras)).count()
1357        + net
1358            .transformers_3w()
1359            .iter()
1360            .filter(|e| carries(&e.extras))
1361            .count();
1362    if dropped > 0 {
1363        warnings.push(
1364            &family.extras_dropped,
1365            format!(
1366                "{dropped} element(s) carry source-format passthrough fields (extras) the {target} \
1367                 writer does not replay; dropped"
1368            ),
1369        );
1370    }
1371}
1372
1373/// Warn when a writer drops the area table. Its own line rather than the
1374/// extras count: `areas` is a typed field, not a passthrough (#330).
1375pub(super) fn warn_dropped_areas(
1376    family: &'static EmitFamily,
1377    target: &str,
1378    net: &BalancedNetwork,
1379    warnings: &mut Diagnostics,
1380) {
1381    if !net.areas().is_empty() {
1382        warnings.push(
1383            &family.areas_dropped,
1384            format!(
1385                "{} area record(s) dropped: the {target} writer emits no area table",
1386                net.areas().len()
1387            ),
1388        );
1389    }
1390}
1391
1392pub(super) fn warn_extra_branch_rating_sets(
1393    family: &'static EmitFamily,
1394    target: &str,
1395    net: &BalancedNetwork,
1396    warnings: &mut Diagnostics,
1397) {
1398    for (branch_index, branch) in net.branches().iter().enumerate() {
1399        for rating in &branch.rating_sets {
1400            warnings.push(
1401                &family.rating_set_dropped,
1402                branch_rating_set_drop_warning(target, branch_index, branch, rating),
1403            );
1404        }
1405    }
1406}
1407
1408/// The declared format ID for a caller-supplied token. Tokens are matched
1409/// case insensitively and accept the historical underscore spelling of a
1410/// hyphenated alias; the ID itself keeps the stable lower case hyphen
1411/// grammar.
1412pub fn format_id_for(
1413    token: &str,
1414) -> std::result::Result<powerio_core::FormatId, powerio_core::Error> {
1415    powerio_core::FormatId::new(token.to_ascii_lowercase().replace('_', "-"))
1416}
1417
1418/// Attach a caller-named source format to a source.
1419fn with_declared_format(
1420    source: powerio_core::Source,
1421    from: Option<&str>,
1422) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
1423    match from {
1424        None => Ok(source),
1425        Some(token) => Ok(source.with_format(format_id_for(token)?)),
1426    }
1427}
1428
1429/// Convert a case file to `to`, optionally forcing the source format with
1430/// `from`.
1431///
1432/// This is the canonical file-conversion helper shared by the bindings. It
1433/// parses `path` once, writes the parsed module to `to`, and returns the
1434/// converted text plus any fidelity findings, read side first. An echo
1435/// (writing back to the source format) returns the retained text with no
1436/// findings.
1437///
1438/// # Errors
1439/// As [`parse`].
1440pub fn convert_file(
1441    path: impl AsRef<std::path::Path>,
1442    to: TargetFormat,
1443    from: Option<&str>,
1444) -> std::result::Result<Conversion, powerio_core::Error> {
1445    let source = with_declared_format(powerio_core::Source::open(path.as_ref())?, from)?;
1446    convert_source(source, to, &WriteOptions::default())
1447}
1448
1449/// Convert a case file with write-time cost policies.
1450pub fn convert_file_with_options(
1451    path: impl AsRef<std::path::Path>,
1452    to: TargetFormat,
1453    from: Option<&str>,
1454    options: &WriteOptions,
1455) -> std::result::Result<Conversion, powerio_core::Error> {
1456    let source = with_declared_format(powerio_core::Source::open(path.as_ref())?, from)?;
1457    convert_source(source, to, options)
1458}
1459
1460/// Convert in-memory case `text` of the named source format `from` (see
1461/// [`target_format_from_name`]) to `to`.
1462///
1463/// Parses `text` once and writes the parsed module to `to` without a
1464/// temporary file. Findings are ordered read side first, as in
1465/// [`convert_file`].
1466///
1467/// # Errors
1468/// As [`parse`].
1469pub fn convert_str(
1470    text: &str,
1471    to: TargetFormat,
1472    from: &str,
1473) -> std::result::Result<Conversion, powerio_core::Error> {
1474    convert_str_with_options(text, to, from, &WriteOptions::default())
1475}
1476
1477/// Convert in-memory case text with write-time cost policies.
1478pub fn convert_str_with_options(
1479    text: &str,
1480    to: TargetFormat,
1481    from: &str,
1482    options: &WriteOptions,
1483) -> std::result::Result<Conversion, powerio_core::Error> {
1484    let source = with_declared_format(
1485        powerio_core::Source::from_bytes("<memory>", text.as_bytes().to_vec())?,
1486        Some(from),
1487    )?;
1488    convert_source(source, to, options)
1489}
1490
1491fn convert_source(
1492    source: powerio_core::Source,
1493    to: TargetFormat,
1494    options: &WriteOptions,
1495) -> std::result::Result<Conversion, powerio_core::Error> {
1496    let module = parse(source)?;
1497    let echoed = options.is_default() && echo_text(&module, to).is_some();
1498    let mut conv = write_as_with_options(&module, to, options)?;
1499    if !echoed {
1500        conv.prepend(module.diagnostics().to_vec());
1501    }
1502    Ok(conv)
1503}
1504
1505/// Write `net` into `out_dir` as the named directory format. This function
1506/// dispatches directory format names for the bindings. PyPSA CSV
1507/// (`pypsa-csv`/`pypsa`) is the one such
1508/// format today; a text format name is rejected by name, pointing at
1509/// [`write_as`]. Returns the write's findings as structured records; render
1510/// them with `diagnostics::render_diagnostics` for a text channel.
1511///
1512/// # Errors
1513/// [`Error::UnknownFormat`] for a non-directory format name; the writer's own
1514/// [`Error`] otherwise.
1515pub fn write_dir(
1516    net: &BalancedNetwork,
1517    to: &str,
1518    out_dir: impl AsRef<std::path::Path>,
1519) -> std::result::Result<Vec<Diagnostic>, powerio_core::Error> {
1520    if is_pypsa_csv_name(to) {
1521        return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.diagnostics);
1522    }
1523    Err(core_error(unknown_directory_format(to)))
1524}
1525
1526fn unknown_directory_format(to: &str) -> Error {
1527    Error::UnknownFormat(format!(
1528        "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
1529         text formats serialize through write_as / to_format"
1530    ))
1531}
1532
1533/// Write `net` into `out_dir` with write-time cost policies: the directory twin
1534/// of [`write_as_with_options`]. Default options are [`write_dir`] exactly.
1535/// The policy's own findings come back ahead of the writer's.
1536///
1537/// # Errors
1538/// As [`write_dir`], plus the cost policy's own [`Error`].
1539pub fn write_dir_with_options(
1540    net: &BalancedNetwork,
1541    to: &str,
1542    out_dir: impl AsRef<std::path::Path>,
1543    options: &WriteOptions,
1544) -> std::result::Result<Vec<Diagnostic>, powerio_core::Error> {
1545    // Refuse an unknown target before the policy runs, so a bad format name is
1546    // reported as one rather than as whatever the cost pass hits first.
1547    if !is_pypsa_csv_name(to) {
1548        return Err(core_error(unknown_directory_format(to)));
1549    }
1550    if options.is_default() {
1551        return write_dir(net, to, out_dir);
1552    }
1553    let (working, mut diagnostics) = apply_write_cost_policy(net, options).map_err(core_error)?;
1554    diagnostics.extend(write_dir(&working, to, out_dir)?);
1555    Ok(diagnostics)
1556}
1557
1558/// Warn when a network with no reference (slack) bus converts to a format
1559/// whose solvers require one. PowerWorld `.pwb` is the one source that
1560/// systematically lacks the designation (the binary does not store it), so
1561/// the silent case would be common; `to_normalized` synthesizes a slack at
1562/// the largest pmax in service generator bus for consumers that need one.
1563fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1564    let needs_ref = matches!(
1565        format,
1566        TargetFormat::Matpower
1567            | TargetFormat::Psse { .. }
1568            | TargetFormat::PowerModelsJson
1569            | TargetFormat::PandapowerJson
1570            | TargetFormat::Pslf
1571            | TargetFormat::SurgeJson
1572    );
1573    if needs_ref {
1574        if let Some(message) = missing_reference_warning(net) {
1575            conv.push(&format.emit_family().reference_missing, message);
1576        }
1577    }
1578}
1579
1580/// The slackless-network warning itself, shared with the PyPSA folder writer
1581/// (which produces `PypsaCsvOutputs`, not a [`Conversion`], so it cannot go
1582/// through [`warn_missing_reference`]).
1583pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
1584    (!net.buses().iter().any(|b| b.kind == BusType::Ref)).then(|| {
1585        "no reference (slack) bus in the source network; power flow tools \
1586         reject such cases; to_normalized synthesizes a slack at the \
1587         largest pmax in service generator bus"
1588            .to_string()
1589    })
1590}
1591
1592/// A normalized network has its tap canonicalized to `1.0` on every line (the
1593/// `0 → 1` rule), but [`Branch::is_transformer`](crate::network::Branch::is_transformer),
1594/// the test these writers use to split lines from transformers, keys off
1595/// `tap != 0`. So a normalized line is written into the transformer section/type.
1596/// The power flow is identical (a unity-ratio, zero-shift transformer equals a
1597/// line), but the label is not, so report the fidelity loss rather than relabel
1598/// it silently. MATPOWER has no separate transformer representation (just a `TAP`
1599/// column), so it is exempt.
1600// `tap == 1.0` / `shift == 0.0` are exact by construction: normalization sets a
1601// line's tap from `effective_tap()` (the literal `1.0`) and its shift from
1602// `0.0 * DEG_TO_RAD` (exactly `0.0`), so an epsilon compare would be wrong here.
1603#[allow(clippy::float_cmp)]
1604fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1605    if matches!(format, TargetFormat::Matpower) {
1606        return;
1607    }
1608    if let Some(message) = normalized_tap_warning(net) {
1609        conv.push(&format.emit_family().element_relabeled, message);
1610    }
1611}
1612
1613/// The normalized-label warning itself, shared with the PyPSA folder writer.
1614// `tap == 1.0` / `shift == 0.0` are exact by construction (see
1615// `warn_normalized_tap`), so an epsilon compare would be wrong here.
1616#[allow(clippy::float_cmp)]
1617pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
1618    if !net.is_normalized() {
1619        return None;
1620    }
1621    // After normalization a line (raw tap 0) and a unity-ratio transformer (raw
1622    // tap 1) both read as tap 1.0 / shift 0.0, so they cannot be told apart. Count
1623    // them together as the branches whose line/transformer label is now ambiguous.
1624    let ambiguous = net
1625        .branches()
1626        .iter()
1627        .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1628        .count();
1629    (ambiguous > 0).then(|| {
1630        format!(
1631            "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1632             shift, so the line/transformer label is not preserved (the power flow \
1633             is identical)"
1634        )
1635    })
1636}
1637
1638/// True when `value` is set and deviates from `reference`: the shared test for
1639/// "does this rating column carry information the target cannot" used by the
1640/// rate_b/rate_c drop warnings.
1641fn nonzero_differs(value: f64, reference: f64) -> bool {
1642    value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1643}
1644
1645/// Set a bus's kind through the `bus_pos` index, leaving Isolated buses alone.
1646/// Shared by the readers that derive bus kinds from generator/slack tables.
1647pub(crate) fn set_bus_kind(
1648    buses: &mut [Bus],
1649    bus_pos: &HashMap<BusId, usize>,
1650    bus: BusId,
1651    kind: BusType,
1652) {
1653    if let Some(&idx) = bus_pos.get(&bus) {
1654        if buses[idx].kind != BusType::Isolated {
1655            buses[idx].kind = kind;
1656        }
1657    }
1658}
1659
1660/// `base_kv` of a bus through the `bus_pos` index; 0.0 for an unknown bus.
1661pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1662    bus_pos
1663        .get(&bus)
1664        .and_then(|&i| buses.get(i))
1665        .map_or(0.0, |b| b.base_kv)
1666}
1667
1668/// Replace characters that would corrupt a quoted or delimited field with
1669/// `replacement`, so a free-form name can't shift or truncate the record it sits
1670/// in. `forbidden` lists the destination's quote, delimiter, and comment chars.
1671/// Returns the value borrowed unchanged when it holds none of them, so the common
1672/// clean-name path allocates nothing.
1673///
1674/// Each text writer calls this at its quoting seam and warns when the result
1675/// differs from the input (the substitution silently alters operator-facing
1676/// names): the PSS/E single-quoted bus name and the PowerWorld double-quoted bus
1677/// name both interpolate a `BalancedNetwork` name straight into a quoted field, where an
1678/// embedded quote (or, for PSS/E, the `/` inline-comment delimiter) would shift
1679/// every later column of the record.
1680/// A line terminator is always replaced, whatever `forbidden` holds: no text
1681/// record format can carry one inside a field, so an embedded `\n` does not
1682/// shift a column, it ends the record and makes everything after it parse as
1683/// a new one. A crafted name could otherwise forge whole records in the
1684/// written file.
1685pub(crate) fn sanitize_quoted<'a>(
1686    value: &'a str,
1687    forbidden: &[char],
1688    replacement: char,
1689) -> std::borrow::Cow<'a, str> {
1690    let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
1691    if value.contains(breaks) {
1692        value
1693            .chars()
1694            .map(|c| if breaks(c) { replacement } else { c })
1695            .collect::<String>()
1696            .into()
1697    } else {
1698        std::borrow::Cow::Borrowed(value)
1699    }
1700}
1701
1702/// Impedance base `v_kv² / base_mva`; 1.0 when either base is missing, so a
1703/// per-unit ↔ ohm conversion on it is the identity.
1704pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1705    if v_kv > 0.0 && base_mva > 0.0 {
1706        v_kv * v_kv / base_mva
1707    } else {
1708        1.0
1709    }
1710}
1711
1712/// Whether a write target is the same format the network was read from.
1713fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1714    matches!(
1715        (target, source),
1716        (TargetFormat::Matpower, SourceFormat::Matpower)
1717            | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1718            | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1719            | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1720            | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1721            | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1722            | (TargetFormat::Pslf, SourceFormat::Pslf)
1723            | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1724            | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1725            | (
1726                TargetFormat::DeepMindOpfDataJson,
1727                SourceFormat::DeepMindOpfDataJson,
1728            )
1729    )
1730}
1731
1732/// JSON number for a finite `f64`; `Value::Null` for `NaN`/`±Inf`.
1733pub(crate) fn jnum(x: f64) -> Value {
1734    serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1735}
1736
1737/// Serialize a built JSON tree into a [`Conversion`], appending one warning that
1738/// names every field where a non-finite `f64` was written as `null` (JSON has no
1739/// `±Inf`/`NaN`). Shared by the JSON writers.
1740pub(crate) fn finish(
1741    family: &'static EmitFamily,
1742    root: Map<String, Value>,
1743    mut warnings: Diagnostics,
1744) -> Conversion {
1745    let value = Value::Object(root);
1746    let mut nulls = BTreeSet::new();
1747    collect_null_keys(&value, &mut nulls);
1748    if !nulls.is_empty() {
1749        warnings.push(
1750            &family.not_a_number,
1751            format!(
1752                "non-finite numeric values written as JSON null in field(s): {}",
1753                nulls.into_iter().collect::<Vec<_>>().join(", ")
1754            ),
1755        );
1756    }
1757    let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1758    Conversion::new(text, warnings)
1759}
1760
1761/// Collect the names of object keys whose value is `null`, anywhere in the tree.
1762fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1763    match value {
1764        Value::Object(map) => {
1765            for (key, val) in map {
1766                if val.is_null() {
1767                    out.insert(key.clone());
1768                } else {
1769                    collect_null_keys(val, out);
1770                }
1771            }
1772        }
1773        Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1774        _ => {}
1775    }
1776}
1777
1778/// Test-only compatibility parse shapes; production code goes through
1779/// [`parse`] and the module type.
1780#[cfg(test)]
1781pub(crate) mod test_parse {
1782    use super::*;
1783
1784    #[derive(Debug)]
1785    pub(crate) struct TestParsed {
1786        pub network: BalancedNetwork,
1787        pub diagnostics: Vec<Diagnostic>,
1788    }
1789
1790    impl TestParsed {
1791        pub(crate) fn rendered_diagnostics(&self) -> Vec<String> {
1792            crate::diagnostics::render_diagnostics(&self.diagnostics)
1793        }
1794    }
1795
1796    fn declared(
1797        source: powerio_core::Source,
1798        from: Option<&str>,
1799    ) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
1800        match from {
1801            None => Ok(source),
1802            Some(token) => Ok(source.with_format(powerio_core::FormatId::new(
1803                token.to_ascii_lowercase().replace('_', "-"),
1804            )?)),
1805        }
1806    }
1807
1808    pub(crate) fn parse_file(
1809        path: impl AsRef<std::path::Path>,
1810        from: Option<&str>,
1811    ) -> std::result::Result<TestParsed, powerio_core::Error> {
1812        let source = declared(powerio_core::Source::open(path.as_ref())?, from)?;
1813        parse(source).map(|module| TestParsed {
1814            diagnostics: module.diagnostics().to_vec(),
1815            network: module.into_value(),
1816        })
1817    }
1818
1819    pub(crate) fn parse_str(
1820        text: &str,
1821        from: &str,
1822    ) -> std::result::Result<TestParsed, powerio_core::Error> {
1823        let source = declared(
1824            powerio_core::Source::from_bytes("<memory>", text.as_bytes().to_vec())?,
1825            Some(from),
1826        )?;
1827        parse(source).map(|module| TestParsed {
1828            diagnostics: module.diagnostics().to_vec(),
1829            network: module.into_value(),
1830        })
1831    }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836    use super::test_parse::{parse_file, parse_str};
1837    use super::*;
1838    use crate::network::SourceFormat;
1839
1840    #[test]
1841    fn sanitize_quoted_always_replaces_line_terminators() {
1842        // A terminator ends the record, so it is replaced whatever the
1843        // caller's delimiter set holds: a name carrying one could otherwise
1844        // forge whole records in a written .raw/.aux/.epc.
1845        for forbidden in [&[][..], &['\''][..], &['"'][..]] {
1846            let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
1847            assert!(
1848                !out.contains('\n') && !out.contains('\r'),
1849                "terminator survived with forbidden={forbidden:?}: {out:?}"
1850            );
1851        }
1852        // A clean value is still borrowed, not copied.
1853        assert!(matches!(
1854            sanitize_quoted("clean name", &['\''], ' '),
1855            std::borrow::Cow::Borrowed(_)
1856        ));
1857    }
1858
1859    #[test]
1860    fn dss_extension_error_names_the_distribution_surface() {
1861        let path = std::env::temp_dir().join(format!(
1862            "powerio-dss-surface-{}-feeder.dss",
1863            std::process::id()
1864        ));
1865        std::fs::write(&path, "New Circuit.feeder\n").unwrap();
1866        let err = parse_file(&path, None).unwrap_err();
1867        let _ = std::fs::remove_file(&path);
1868        assert!(err.to_string().contains("distribution"), "got: {err}");
1869    }
1870
1871    #[test]
1872    fn io_error_names_the_path() {
1873        let path =
1874            std::env::temp_dir().join(format!("powerio-no-such-case-{}.m", std::process::id()));
1875        let err = parse_file(&path, None).unwrap_err();
1876        assert_eq!(err.category(), powerio_core::ErrorCategory::Io);
1877        let msg = err.to_string();
1878        assert!(
1879            msg.contains(&path.display().to_string()),
1880            "the io failure must name the path: {msg}"
1881        );
1882    }
1883
1884    #[test]
1885    fn a_directory_is_refused_as_a_directory() {
1886        // A versioned dataset directory: extension inference would read ".07"
1887        // off the name and misdiagnose the mistake as a format problem.
1888        let dir = std::env::temp_dir().join(format!("pglib-opf-23.07-{}", std::process::id()));
1889        std::fs::create_dir_all(&dir).unwrap();
1890        let err = parse_file(&dir, None).unwrap_err();
1891        std::fs::remove_dir_all(&dir).unwrap();
1892        let msg = err.to_string();
1893        assert!(msg.contains("is a directory"), "got: {msg}");
1894        assert!(msg.contains(&dir.display().to_string()), "got: {msg}");
1895        assert!(msg.contains("PyPSA CSV folder"), "got: {msg}");
1896    }
1897
1898    #[test]
1899    fn unknown_format_error_lists_the_accepted_names() {
1900        let err = parse_str("anything", "not-a-format").unwrap_err();
1901        let msg = err.to_string();
1902        assert!(msg.contains("not-a-format"), "got: {msg}");
1903        assert!(msg.contains("accepted names:"), "got: {msg}");
1904        assert!(msg.contains(SOURCE_FORMAT_NAMES), "got: {msg}");
1905    }
1906
1907    #[test]
1908    fn the_accepted_name_list_matches_the_matcher() {
1909        use routing::TransmissionFormat as TF;
1910        // Every alias in the printed list resolves.
1911        let mut canonical = Vec::new();
1912        for clause in SOURCE_FORMAT_NAMES.split(", ") {
1913            for (i, alias) in clause.split('/').enumerate() {
1914                let resolved = routing::transmission_format_from_name(alias);
1915                assert!(
1916                    resolved.is_some(),
1917                    "listed alias `{alias}` does not resolve"
1918                );
1919                if i == 0 {
1920                    canonical.push(resolved.unwrap());
1921                }
1922            }
1923        }
1924        // Every parseable format is listed. Gridfm is the one matcher entry
1925        // with no parse_file arm (datasets go through the read_dir surface).
1926        for format in [
1927            TF::Matpower,
1928            TF::PowerModelsJson,
1929            TF::EgretJson,
1930            TF::Psse,
1931            TF::Psse34,
1932            TF::Psse35,
1933            TF::PowerWorld,
1934            TF::PandapowerJson,
1935            TF::PypsaCsv,
1936            TF::Pslf,
1937            TF::Pwb,
1938            TF::Goc3Json,
1939            TF::SurgeJson,
1940            TF::DeepMindOpfDataJson,
1941        ] {
1942            assert!(
1943                canonical.contains(&format),
1944                "{} is missing from SOURCE_FORMAT_NAMES",
1945                format.name()
1946            );
1947        }
1948    }
1949
1950    #[test]
1951    fn a_case_with_generators_and_no_cost_data_warns() {
1952        let costless = "\
1953function mpc = nocost
1954mpc.version = '2';
1955mpc.baseMVA = 100;
1956mpc.bus = [
1957\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1958\t2\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1959];
1960mpc.gen = [
1961\t1\t60\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
1962];
1963mpc.branch = [
1964\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1965];
1966";
1967        // The parse itself stays silent: whether a case carries costs is the
1968        // case's business, and a conversion leg must not count it. The
1969        // solver-ready copy is where a zero objective becomes real.
1970        let parsed = parse_str(costless, "matpower").unwrap();
1971        assert!(
1972            parsed.rendered_diagnostics().is_empty(),
1973            "{:?}",
1974            parsed.rendered_diagnostics()
1975        );
1976        let normalized = parsed
1977            .network
1978            .to_normalized_with_options(&crate::NormalizeOptions::default())
1979            .unwrap();
1980        let absent: Vec<_> = normalized
1981            .diagnostics
1982            .iter()
1983            .filter(|d| d.code() == "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT")
1984            .collect();
1985        assert_eq!(absent.len(), 1, "{:?}", normalized.warnings);
1986        assert!(absent[0].message().contains("no cost data"), "{absent:?}");
1987        assert!(absent[0].message().contains("1 in-service"), "{absent:?}");
1988
1989        // The same case with a gencost table is silent.
1990        let costed = format!("{costless}mpc.gencost = [\n\t2\t0\t0\t3\t0.01\t40\t0;\n];\n");
1991        let parsed = parse_str(&costed, "matpower").unwrap();
1992        let normalized = parsed
1993            .network
1994            .to_normalized_with_options(&crate::NormalizeOptions::default())
1995            .unwrap();
1996        assert!(
1997            normalized
1998                .diagnostics
1999                .iter()
2000                .all(|d| d.code() != "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT"),
2001            "{:?}",
2002            normalized.warnings
2003        );
2004    }
2005
2006    #[test]
2007    fn distribution_from_token_error_names_the_distribution_surface() {
2008        for token in ["dss", "pmd", "bmopf"] {
2009            let err = parse_str("anything", token).unwrap_err();
2010            assert!(
2011                err.to_string().contains("one module family"),
2012                "{token}: {err}"
2013            );
2014        }
2015        // A genuinely unknown token still echoes plainly.
2016        let err = parse_str("anything", "nonesuch").unwrap_err();
2017        assert!(err.to_string().contains("nonesuch"));
2018    }
2019
2020    #[test]
2021    fn byte_order_mark_is_retained_and_echoed() {
2022        // Windows tooling saves case files with a UTF-8 byte order mark. The
2023        // parser decodes a mark free slice of the one retained buffer, and an
2024        // unchanged same format write reproduces the original bytes, mark
2025        // included.
2026        let case = "\u{feff}function mpc = t\n\
2027                    mpc.version = '2';\n\
2028                    mpc.baseMVA = 100;\n\
2029                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2030                    mpc.gen = [];\n\
2031                    mpc.branch = [];\n";
2032        let source = powerio_core::Source::from_bytes("case.m", case.as_bytes().to_vec()).unwrap();
2033        let module = parse(source.with_format(format_id_for("matpower").unwrap())).unwrap();
2034        assert_eq!(module.value().buses().len(), 1);
2035        assert!(
2036            module.diagnostics().is_empty(),
2037            "{:?}",
2038            module.diagnostics()
2039        );
2040        let echo = write_as(&module, TargetFormat::Matpower).unwrap();
2041        assert_eq!(echo.text, case, "the echo reproduces the mark exactly");
2042    }
2043
2044    #[test]
2045    fn canonical_format_bypasses_same_format_matpower_echo() {
2046        let case = "function mpc = t\n\
2047                    % a comment the canonical writer does not keep\n\
2048                    mpc.version = '2';\n\
2049                    mpc.baseMVA = 100;\n\
2050                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2051                    mpc.gen = [];\n\
2052                    mpc.branch = [];\n";
2053        let source = powerio_core::Source::from_bytes("case.m", case.as_bytes().to_vec()).unwrap();
2054        let module =
2055            parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap())).unwrap();
2056        assert_eq!(
2057            write_as(&module, TargetFormat::Matpower).unwrap().text,
2058            case
2059        );
2060
2061        let net = module.into_value();
2062        let canonical = net.to_canonical_format(TargetFormat::Matpower).unwrap();
2063        assert_ne!(canonical.text, case);
2064        let reparsed = parse_str(&canonical.text, "matpower").unwrap();
2065        assert_eq!(reparsed.network.buses().len(), 1);
2066    }
2067
2068    #[test]
2069    fn package_json_error_names_the_package_reader() {
2070        let err = sniff_json(r#"{"model_kind":"balanced","model":{}}"#).unwrap_err();
2071        assert!(err.to_string().contains(".pio.json"), "got: {err}");
2072    }
2073
2074    #[test]
2075    fn source_format_strings_round_trip_to_a_target() {
2076        // The bindings expose `source_format` as its `name()` token, and
2077        // `to_format` routes that string back through `target_format_from_name`.
2078        // Every writable source format must resolve; the legacy `{:?}` spelling
2079        // (the pre-0.9 property value, issue #75) must keep resolving for
2080        // callers that stored it.
2081        for (sf, want) in [
2082            (SourceFormat::Matpower, TargetFormat::Matpower),
2083            (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
2084            (SourceFormat::EgretJson, TargetFormat::EgretJson),
2085            (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
2086            (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
2087            (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
2088            (SourceFormat::Pslf, TargetFormat::Pslf),
2089            (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
2090            (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
2091            (
2092                SourceFormat::DeepMindOpfDataJson,
2093                TargetFormat::DeepMindOpfDataJson,
2094            ),
2095        ] {
2096            let token = sf.name();
2097            assert_eq!(
2098                target_format_from_name(token),
2099                Some(want),
2100                "source_format {token:?} did not round-trip"
2101            );
2102            let legacy = format!("{sf:?}");
2103            assert_eq!(
2104                target_format_from_name(&legacy),
2105                Some(want),
2106                "legacy spelling {legacy:?} did not round-trip"
2107            );
2108        }
2109        // The derived/in-memory source formats have no writer target, and
2110        // neither does the read only .pwb binary.
2111        for sf in [
2112            SourceFormat::InMemory,
2113            SourceFormat::Normalized,
2114            SourceFormat::Gridfm,
2115            SourceFormat::PypsaCsv,
2116            SourceFormat::PowerWorldBinary,
2117        ] {
2118            assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
2119        }
2120    }
2121}