Skip to main content

powerio/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_file`] reads BalancedNetwork cases, detecting the format from its extension;
12//! [`parse_display_file`] reads display artifacts such as PowerWorld `.pwd`.
13//! [`write_as`] serializes a `BalancedNetwork` to text targets. Directory formats,
14//! such as PyPSA CSV folders, use explicit filesystem helpers. Non-finite
15//! numeric values, such as MATPOWER `Inf`/`NaN` angle limits, are written as
16//! JSON `null`.
17//!
18//! # Fidelity behavior
19//!
20//! Conversion is two-tier:
21//!
22//! - **Same format writes return the original text.** A reader keeps its source
23//!   text (see [`BalancedNetwork`]), so writing back to the same format returns every
24//!   field, comment, and numeric token.
25//! - **Cross-format keeps maximal fidelity with itemized loss.** Whatever the
26//!   target format cannot represent is reported in the [`Conversion`] `warnings`,
27//!   never dropped silently. On the read side, readers itemize what they ignore
28//!   in [`Parsed`] `warnings`.
29
30use std::collections::{BTreeSet, HashMap};
31use std::fmt;
32use std::str::FromStr;
33use std::sync::Arc;
34
35use serde_json::{Map, Value};
36
37use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
38use crate::network::{BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat};
39use crate::{Error, Result};
40use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
41
42mod egret;
43#[doc(hidden)]
44pub mod goc3;
45mod matpower;
46mod opfdata;
47mod pandapower;
48mod powermodels;
49pub mod powerworld;
50mod pslf;
51mod psse;
52mod pypsa;
53pub mod routing;
54mod surge;
55
56pub use egret::{parse_egret_json, write_egret_json};
57pub use goc3::parse_goc3_json;
58pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
59pub use opfdata::parse_deepmind_opfdata_json;
60pub use pandapower::{parse_pandapower_json, write_pandapower_json};
61pub use powermodels::{parse_powermodels_json, write_powermodels_json};
62pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
63pub use pslf::{parse_pslf, write_pslf};
64pub use psse::{parse_psse, write_psse, write_psse_rev};
65pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
66pub use surge::{parse_surge_json, write_surge_json};
67
68/// A target interchange format. See [`write_as`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum TargetFormat {
72    /// PowerModels.jl network data JSON.
73    PowerModelsJson,
74    /// egret `ModelData` JSON.
75    EgretJson,
76    /// PSS/E `.raw` at the given revision. `rev` selects the record layout the
77    /// writer emits (33, 34, or 35); 33 is the historical default. The reader
78    /// takes the revision from the file header, so this only affects writes.
79    Psse { rev: u32 },
80    /// PowerWorld auxiliary `.aux`.
81    PowerWorld,
82    /// pandapower `pandapowerNet` JSON.
83    PandapowerJson,
84    /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source).
85    Matpower,
86    /// Compatibility alias for [`BalancedNetwork::to_json`] and [`BalancedNetwork::from_json`].
87    /// New code should call those methods directly.
88    #[doc(hidden)]
89    PowerioJson,
90    /// GE PSLF `.epc` (round-trip; byte-exact when the case kept its source).
91    Pslf,
92    /// ARPA-E GO Challenge 3 JSON input data. This is read only except for
93    /// same format source echo when the parsed network still carries its source.
94    Goc3Json,
95    /// Surge native JSON network document.
96    SurgeJson,
97    /// One JSON document from a DeepMind OPFData release. Read only except for
98    /// an exact write back to the retained source format.
99    DeepMindOpfDataJson,
100}
101
102impl TargetFormat {
103    /// Conventional file extension for this format (no leading dot).
104    #[must_use]
105    pub fn extension(self) -> &'static str {
106        match self {
107            TargetFormat::PowerModelsJson
108            | TargetFormat::EgretJson
109            | TargetFormat::PandapowerJson
110            | TargetFormat::PowerioJson
111            | TargetFormat::Goc3Json
112            | TargetFormat::SurgeJson
113            | TargetFormat::DeepMindOpfDataJson => "json",
114            TargetFormat::Psse { .. } => "raw",
115            TargetFormat::PowerWorld => "aux",
116            TargetFormat::Matpower => "m",
117            TargetFormat::Pslf => "epc",
118        }
119    }
120
121    /// Human-readable format name for diagnostics.
122    #[must_use]
123    pub fn label(self) -> &'static str {
124        match self {
125            TargetFormat::PowerModelsJson => "PowerModels JSON",
126            TargetFormat::EgretJson => "egret JSON",
127            TargetFormat::Psse { .. } => "PSS/E .raw",
128            TargetFormat::PowerWorld => "PowerWorld .aux",
129            TargetFormat::PandapowerJson => "pandapower JSON",
130            TargetFormat::Matpower => "MATPOWER .m",
131            TargetFormat::PowerioJson => "PowerIO JSON",
132            TargetFormat::Pslf => "PSLF .epc",
133            TargetFormat::Goc3Json => "GO Challenge 3 JSON",
134            TargetFormat::SurgeJson => "Surge JSON",
135            TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
136        }
137    }
138
139    /// Canonical API token for this format.
140    #[must_use]
141    pub fn token(self) -> &'static str {
142        match self {
143            TargetFormat::PowerModelsJson => "powermodels-json",
144            TargetFormat::EgretJson => "egret-json",
145            TargetFormat::Psse { rev: 34 } => "psse34",
146            TargetFormat::Psse { rev: 35 } => "psse35",
147            TargetFormat::Psse { .. } => "psse",
148            TargetFormat::PowerWorld => "powerworld",
149            TargetFormat::PandapowerJson => "pandapower-json",
150            TargetFormat::Matpower => "matpower",
151            TargetFormat::PowerioJson => "powerio-json",
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`. The
247/// `powerio-json`/`powerio`/`json` names remain
248/// compatibility aliases for the model JSON methods.
249/// Case-insensitive. The one place the bindings (Python, C ABI) share, so a new
250/// text format means one new arm here, not three. PyPSA CSV folders, GridFM
251/// datasets, and PowerWorld `.pwb` are directory or read only inputs with no
252/// text target; they are routed by [`crate::format::routing`].
253///
254/// The `powermodelsjson`/`egretjson`/`pandapowerjson` aliases let a
255/// [`SourceFormat`]'s string form (`{:?}` lowercased, e.g. `"PowerModelsJson"`)
256/// round-trip back to a target, so `net.to_format(other.source_format)` works
257/// for every format.
258#[must_use]
259pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
260    Some(match routing::transmission_format_from_name(name)? {
261        TransmissionFormat::Matpower => TargetFormat::Matpower,
262        TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
263        TransmissionFormat::EgretJson => TargetFormat::EgretJson,
264        TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
265        TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
266        TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
267        TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
268        TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
269        TransmissionFormat::PowerioJson => TargetFormat::PowerioJson,
270        TransmissionFormat::Pslf => TargetFormat::Pslf,
271        TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
272        TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
273        TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
274        TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
275            return None;
276        }
277    })
278}
279
280/// Output of a display parse. PowerWorld `.pwd` produces
281/// [`DisplayData::PowerWorld`]; a geographic sidecar produces
282/// [`DisplayData::Geo`].
283#[derive(Debug, Clone, PartialEq)]
284#[non_exhaustive]
285pub enum DisplayData {
286    /// PowerWorld oneline display data.
287    PowerWorld(PwdDisplay),
288    /// A standalone geographic layer.
289    Geo(crate::geo::GeoLayer),
290}
291
292impl DisplayData {
293    /// The display format represented by this value.
294    #[must_use]
295    pub fn format(&self) -> DisplayFormat {
296        match self {
297            DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
298            DisplayData::Geo(_) => DisplayFormat::GeoJson,
299        }
300    }
301}
302
303fn display_file_guidance() -> Error {
304    Error::UnknownFormat(
305        "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
306         use parse_display_file(path, None)"
307            .into(),
308    )
309}
310
311/// Parse display bytes in the named display `format`.
312///
313/// # Errors
314/// [`Error::UnknownFormat`] if `format` is not a display format; otherwise the
315/// reader's own [`Error`] on malformed input.
316pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
317    let fmt =
318        display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
319    match fmt {
320        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
321            bytes,
322        )?)),
323        // The tolerant reader's own notes are available through
324        // `GeoLayer::parse_bytes` for callers that want them.
325        DisplayFormat::GeoJson => Ok(DisplayData::Geo(
326            crate::geo::GeoLayer::parse_bytes(bytes, None)?.layer,
327        )),
328    }
329}
330
331/// Parse the display file at `path`, choosing the reader from `from` or, when
332/// `None`, from the extension. A `.pwd` extension selects PowerWorld display
333/// data.
334///
335/// # Errors
336/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension cannot
337/// be mapped; [`Error::Io`] if the file cannot be read; the reader's own
338/// [`Error`] on malformed input.
339pub fn parse_display_file(
340    path: impl AsRef<std::path::Path>,
341    from: Option<&str>,
342) -> Result<DisplayData> {
343    let path = path.as_ref();
344    let fmt = match from {
345        Some(f) => {
346            display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
347        }
348        None => match path
349            .extension()
350            .and_then(|e| e.to_str())
351            .map(str::to_ascii_lowercase)
352            .as_deref()
353        {
354            Some("pwd") => DisplayFormat::PowerWorld,
355            Some("geojson") => DisplayFormat::GeoJson,
356            // `.geo.json` is the canonical layer name; a bare `.json` stays
357            // ambiguous (it is usually a case file).
358            Some("json")
359                if path
360                    .file_name()
361                    .and_then(|name| name.to_str())
362                    .is_some_and(|name| {
363                        name.to_ascii_lowercase()
364                            .ends_with(crate::geo::GEO_LAYER_EXTENSION)
365                    }) =>
366            {
367                DisplayFormat::GeoJson
368            }
369            other => {
370                return Err(Error::UnknownFormat(format!(
371                    "cannot infer display format from file extension {other:?}; \
372                     pass an explicit display format"
373                )));
374            }
375        },
376    };
377    let bytes = std::fs::read(path)?;
378    match fmt {
379        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
380            &bytes,
381        )?)),
382        DisplayFormat::GeoJson => Ok(DisplayData::Geo(
383            crate::geo::GeoLayer::parse_bytes(&bytes, path.file_name().and_then(|n| n.to_str()))?
384                .layer,
385        )),
386    }
387}
388
389/// Whether a format name means a PyPSA CSV folder. PyPSA folders are directory
390/// inputs, not text targets, so they have no [`TargetFormat`] arm; this is the
391/// companion alias matcher to [`target_format_from_name`] and the one place the
392/// PyPSA aliases live.
393fn is_pypsa_csv_name(name: &str) -> bool {
394    matches!(
395        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
396        "pypsacsv" | "pypsa"
397    )
398}
399
400/// Whether a source format name means PSLF EPC.
401fn is_pslf_name(name: &str) -> bool {
402    matches!(
403        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
404        "pslf" | "epc" | "pslfepc"
405    )
406}
407
408/// Parse the case file at `path`, choosing the reader from `from` (the
409/// [`target_format_from_name`] names plus `pypsa-csv`/`pypsa`, `pwb`, `pslf`,
410/// and `epc`) or, when `None`, from the path: a directory containing
411/// `network.csv` parses as a PyPSA CSV folder (any other directory fails:
412/// [`Error::UnknownFormat`] when its name maps to no extension, the I/O error
413/// otherwise), and a file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`/`epc`),
414/// case insensitively (issue #97: `.RAW` is as common as `.raw` in the wild). A
415/// `.json` file is classified by top level shape markers: pandapower
416/// (`"_class": "pandapowerNet"`), egret (`elements` and `system`), GO Challenge
417/// 3 (`network` plus `time_series_input`/`reliability`), Surge JSON
418/// (`format: "surge-json"`), OPFData (`grid`, `solution`, and `metadata`),
419/// powerio-json (`buses` plus network keys), and PowerModels JSON (`baseMVA`,
420/// `branch`, `gen`, or `gencost`). JSON matching
421/// distribution markers, ambiguous markers, or no known markers returns
422/// [`Error::UnknownFormat`]. Pass `from` to force a
423/// transmission format. PowerWorld `.pwb` is a binary read only format with no
424/// retained source; PSLF `.epc` is text and has a writer. Returns [`Parsed`]:
425/// the network plus the reader's fidelity warnings.
426///
427/// The one path-based parser the CLI and the Python/C/Julia bindings share (each
428/// exposes the same `parse_file(path, from)` shape), so adding a source format is
429/// one edit here, not one per binding. For in-memory text use [`parse_str`].
430///
431/// # Errors
432/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension can't be
433/// mapped; [`Error::Io`] if the file can't be read; the reader's own [`Error`]
434/// on malformed input.
435pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
436    let path = path.as_ref();
437    // PyPSA CSV folders are directories, not files; dispatch them before any
438    // extension logic. `from` accepts the pypsa aliases, and a bare directory
439    // with a `network.csv` auto-detects.
440    if from.is_some_and(is_pypsa_csv_name)
441        || (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
442    {
443        return pypsa::read_pypsa_csv_folder(path);
444    }
445    // PowerWorld `.pwb` is binary and read only; dispatch it before the text
446    // read. `from` accepts "pwb" for files with a different extension.
447    let ext = path
448        .extension()
449        .and_then(|e| e.to_str())
450        .map(str::to_ascii_lowercase);
451    if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
452        || (from.is_none() && ext.as_deref() == Some("pwb"))
453    {
454        let bytes = std::fs::read(path)?;
455        let stem = path.file_stem().and_then(|s| s.to_str());
456        // The binary reader is total (no fidelity warnings); wrap its network
457        // in the shared [`Parsed`] shape.
458        let network = powerworld::parse_pwb(&bytes, stem)?;
459        return Ok(Parsed::without_document(network, Vec::new()));
460    }
461    if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
462        let text = std::fs::read_to_string(path)?;
463        let stem = path.file_stem().and_then(|s| s.to_str());
464        let mut warnings = Vec::new();
465        let source = strip_bom(Arc::new(text), &mut warnings);
466        let network = pslf::parse_pslf_source(source, stem, &mut warnings)?;
467        reject_empty_case(&network, "PSLF .epc")?;
468        return Ok(Parsed::without_document(network, warnings));
469    }
470    if from
471        .and_then(target_format_from_name)
472        .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
473        && matches!(ext.as_deref(), Some("pt" | "gz"))
474    {
475        return Err(Error::UnknownFormat(
476            "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
477                .into(),
478        ));
479    }
480    // Settle the format before touching the file: an unmapped or binary
481    // extension must surface as UnknownFormat, not as the UTF-8 read error
482    // the text formats' loader would hit first. `.pwd` gets its own arm
483    // because the display sibling ships next to every case file in the wild
484    // and carries no case data.
485    if from.is_none() && ext.as_deref() == Some("pwd") {
486        return Err(display_file_guidance());
487    }
488    let fmt_hint = match from {
489        Some(f) => {
490            if display_format_from_name(f).is_some() {
491                return Err(display_file_guidance());
492            }
493            Some(target_format_from_name(f).ok_or_else(|| unknown_source_format(f))?)
494        }
495        None => {
496            // Everything but `.json` (sniffed below) resolves without the text.
497            match ext.as_deref() {
498                Some("m") => Some(TargetFormat::Matpower),
499                Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
500                Some("aux") => Some(TargetFormat::PowerWorld),
501                Some("json") => None,
502                Some("dss") => return Err(unknown_source_format("dss")),
503                other => {
504                    return Err(Error::UnknownFormat(format!(
505                        "cannot infer from file extension {other:?}; \
506                         pass an explicit source format"
507                    )));
508                }
509            }
510        }
511    };
512    // Read the file once into an owned buffer; the reader moves it straight into
513    // the retained source (byte-exact round-trip) with no copy. Sniffing a
514    // `.json` borrows the text before the move.
515    let text = std::fs::read_to_string(path)?;
516    let fmt = match fmt_hint {
517        Some(fmt) => fmt,
518        None => sniff_json(&text)?,
519    };
520    // The file stem is the name hint for formats that don't carry their own name.
521    let stem = path.file_stem().and_then(|s| s.to_str());
522    read_source(Arc::new(text), fmt, stem)
523}
524
525/// Strip a leading UTF-8 byte order mark before the reader sees the text.
526/// Windows tooling saves case files with one, and every text reader here
527/// (serde_json first among them) treats it as garbage in the first token. The
528/// retained source loses the mark, so a same-format echo differs by exactly
529/// those three bytes; the warning itemizes that per the fidelity policy.
530fn strip_bom(source: Arc<String>, warnings: &mut Vec<String>) -> Arc<String> {
531    let Some(stripped) = source.strip_prefix('\u{feff}') else {
532        return source;
533    };
534    warnings.push(
535        "leading UTF-8 byte order mark removed; a same-format write returns the text without it"
536            .to_owned(),
537    );
538    Arc::new(stripped.to_owned())
539}
540
541/// Read an owned `source` buffer as `fmt`, using `name_hint` (e.g. the file
542/// stem) when the format carries no name of its own. The single format→reader
543/// map: [`parse_file`] and [`parse_str`] both funnel through it, so every format
544/// is dispatched the same way. Each reader takes the owned `Arc` so
545/// it moves the buffer straight into the retained source (no copy) and is free
546/// to specialize its parse internally. Owns the [`Parsed`] warnings vector;
547/// readers that report fidelity loss append to it.
548fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
549    let mut warnings = Vec::new();
550    let source = strip_bom(source, &mut warnings);
551    let mut document = None;
552    let net = match fmt {
553        TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
554        TargetFormat::PowerModelsJson => {
555            powermodels::parse_powermodels_json_source(source, name_hint, &mut warnings)
556        }
557        TargetFormat::Psse { .. } => psse::parse_psse_source(source, name_hint, &mut warnings),
558        TargetFormat::PowerWorld => {
559            powerworld::parse_powerworld_source(source, name_hint, &mut warnings)
560        }
561        TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
562        TargetFormat::PandapowerJson => {
563            pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
564        }
565        // The canonical snapshot: validated deserialization of the model itself.
566        // It carries its own name and source_format, so the hint doesn't apply.
567        TargetFormat::PowerioJson => BalancedNetwork::from_json(&source),
568        // PSLF read normally enters through the `is_pslf_name`/`.epc` fast path in
569        // parse_file / parse_str; this arm keeps the funnel total.
570        TargetFormat::Pslf => pslf::parse_pslf_source(source, name_hint, &mut warnings),
571        TargetFormat::Goc3Json => {
572            goc3::parse_goc3_source(source, name_hint, &mut warnings).map(|(net, goc3)| {
573                document = Some(SourceDocument::Goc3(goc3));
574                net
575            })
576        }
577        TargetFormat::SurgeJson => surge::parse_surge_source(source, name_hint, &mut warnings),
578        TargetFormat::DeepMindOpfDataJson => {
579            opfdata::parse_opfdata_source(source, name_hint, &mut warnings)
580        }
581    }?;
582    reject_empty_case(&net, fmt.label())?;
583    Ok(Parsed {
584        network: net,
585        warnings,
586        document,
587    })
588}
589
590/// Geographic metadata for a reader that harvested longitude/latitude
591/// coordinates: `Some` once any bus carries a location, so a case without
592/// coordinates serializes exactly as before. The space is stamped geographic
593/// only when every point fits longitude/latitude bounds; a source that
594/// violates its format's own convention (projected meters in a pandapower
595/// `geo` column) reads as unknown instead of claiming WGS84.
596pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
597    let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
598    located.peek()?;
599    let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
600    Some(crate::geo::GeoMeta {
601        space: if in_bounds {
602            crate::geo::CoordinateSpace::Geographic { crs: None }
603        } else {
604            crate::geo::CoordinateSpace::Unknown
605        },
606        kind: None,
607    })
608}
609
610/// A case with no buses is content-free for every consumer. Most readers
611/// already reject it on a missing required table, but a JSON carrying only
612/// `baseMVA` would otherwise parse to a hollow network; reject it in the
613/// [`read_source`] funnel so every parse path (file and in-memory) is guarded,
614/// and in the PyPSA folder reader, which bypasses the funnel.
615pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
616    if net.buses.is_empty() {
617        return Err(Error::FormatRead {
618            format,
619            message: "case has no buses".into(),
620        });
621    }
622    Ok(())
623}
624
625/// An unrecognized source format token. When the token names a distribution
626/// format (`dss`, `pmd`, `bmopf`), the error points at the distribution
627/// surface instead of echoing the token: this parser reads only balanced
628/// transmission formats.
629fn unknown_source_format(name: &str) -> Error {
630    if let Some(dist) = routing::distribution_format_from_name(name) {
631        return Error::UnknownFormat(format!(
632            "`{}` is a distribution format, and this parser reads only balanced \
633             transmission formats; use the distribution surface (powerio_dist::parse_file, \
634             pio_dist_parse_file in C, or the format-routed parse_file in the bindings)",
635            dist.name()
636        ));
637    }
638    Error::UnknownFormat(name.to_string())
639}
640
641/// The JSON formats share the `.json` extension, so an explicit source format
642/// isn't always given. Classification lives here so the CLI and bindings use
643/// the same top level markers as the Rust parsers.
644fn sniff_json(text: &str) -> Result<TargetFormat> {
645    match routing::classify_json_text(text) {
646        JsonClass::Package => Err(Error::UnknownFormat(
647            "JSON is a .pio.json package; read it with the package entry points \
648             (pio_package_parse_str in C, powerio.Package.from_json in Python, \
649             read_package in Julia)"
650                .into(),
651        )),
652        JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
653            transmission_json_target(format)
654        }
655        JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
656            Err(Error::UnknownFormat(format!(
657                "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
658                format.name()
659            )))
660        }
661        JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
662            "ambiguous JSON markers; pass an explicit source format".into(),
663        )),
664        JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
665            "cannot infer JSON format; pass an explicit source format".into(),
666        )),
667    }
668}
669
670fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
671    match format {
672        TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
673        TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
674        TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
675        TransmissionFormat::PowerioJson => Ok(TargetFormat::PowerioJson),
676        TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
677        TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
678        TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
679        other => Err(Error::UnknownFormat(format!(
680            "JSON classifier returned non-JSON transmission format `{}`",
681            other.name()
682        ))),
683    }
684}
685
686/// Parse in-memory case `text` of the named `format` (see
687/// [`target_format_from_name`]). Returns [`Parsed`]: the network plus the
688/// reader's fidelity warnings.
689///
690/// # Errors
691/// [`Error::UnknownFormat`] if `format` is unrecognized; the reader's own
692/// [`Error`] on malformed input.
693pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
694    parse_str_with_name(text, format, None)
695}
696
697/// [`parse_str`] with a name hint for formats that carry no name of their own
698/// (the role the file stem plays in [`parse_file`]). Lets a caller that already
699/// read a file's text keep the stem-derived case name without going back
700/// through the filesystem.
701///
702/// # Errors
703/// As [`parse_str`].
704pub fn parse_str_with_name(text: &str, format: &str, name_hint: Option<&str>) -> Result<Parsed> {
705    if is_pslf_name(format) {
706        let mut warnings = Vec::new();
707        let source = strip_bom(Arc::new(text.to_owned()), &mut warnings);
708        let network = pslf::parse_pslf_source(source, name_hint, &mut warnings)?;
709        reject_empty_case(&network, "PSLF .epc")?;
710        return Ok(Parsed::without_document(network, warnings));
711    }
712    let fmt = target_format_from_name(format).ok_or_else(|| unknown_source_format(format))?;
713    read_source(Arc::new(text.to_owned()), fmt, name_hint)
714}
715
716/// Parse in-memory case `bytes` of the named `format`. Accepts every name
717/// [`parse_str`] does, plus `pwb`: PowerWorld binary has no text form, so this
718/// is the only in-memory entry point that reaches it. Text formats decode as
719/// UTF-8 and take the [`parse_str`] path from there.
720///
721/// A caller that already holds the file's bytes — an upload, an archive
722/// member, a database blob — parses them here rather than staging a temporary
723/// file for [`parse_file`].
724///
725/// # Errors
726/// [`Error::UnknownFormat`] if `format` is unrecognized; [`Error::FormatRead`]
727/// if a text format's bytes are not UTF-8; the reader's own [`Error`] on
728/// malformed input.
729pub fn parse_bytes(bytes: &[u8], format: &str) -> Result<Parsed> {
730    parse_bytes_with_name(bytes, format, None)
731}
732
733/// [`parse_bytes`] with a name hint, as [`parse_str_with_name`] is to
734/// [`parse_str`].
735///
736/// # Errors
737/// As [`parse_bytes`].
738pub fn parse_bytes_with_name(
739    bytes: &[u8],
740    format: &str,
741    name_hint: Option<&str>,
742) -> Result<Parsed> {
743    if format.eq_ignore_ascii_case("pwb") {
744        // Total reader, no fidelity warnings; same call parse_file makes.
745        let network = powerworld::parse_pwb(bytes, name_hint)?;
746        return Ok(Parsed::without_document(network, Vec::new()));
747    }
748    // A display format reaches a different return type, so name the entry
749    // point that returns it instead of failing as an unknown case format.
750    if display_format_from_name(format).is_some() {
751        return Err(Error::UnknownFormat(format!(
752            "{format} is display data, not a BalancedNetwork case; \
753             use parse_display_bytes(bytes, \"{format}\")"
754        )));
755    }
756    let text = std::str::from_utf8(bytes).map_err(|e| Error::FormatRead {
757        format: "case text",
758        message: format!("not valid UTF-8: {e}"),
759    })?;
760    parse_str_with_name(text, format, name_hint)
761}
762
763/// Output of a parse: the network plus the reader's fidelity warnings,
764/// tables and columns the model cannot carry, reported instead of dropped
765/// silently. Empty for readers that don't report read warnings (currently
766/// readers that do not need to reduce any source fields).
767///
768/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
769/// never constructs it, leaving room to add parse metadata without a breaking
770/// change.
771#[derive(Debug, Clone)]
772#[non_exhaustive]
773pub struct Parsed {
774    pub network: BalancedNetwork,
775    pub warnings: Vec<String>,
776    /// The source document for formats whose downstream adapters reuse the
777    /// reader's parse (see [`SourceDocument`]); `None` for every other format.
778    pub document: Option<SourceDocument>,
779}
780
781impl Parsed {
782    /// Wrap a reader result for a format without a shared source document.
783    pub(crate) fn without_document(network: BalancedNetwork, warnings: Vec<String>) -> Self {
784        Self {
785            network,
786            warnings,
787            document: None,
788        }
789    }
790}
791
792/// A format's source document, parsed once by the reader and handed forward so
793/// downstream adapters derive their data from the same parse instead of
794/// reparsing the retained source text. Today that is the GOC3 document, which
795/// the operating point extractor in `powerio-pkg` consumes.
796#[derive(Debug, Clone)]
797#[non_exhaustive]
798pub enum SourceDocument {
799    Goc3(Arc<goc3::Goc3Document>),
800}
801
802/// Output of a conversion: the serialized text plus any fidelity warnings:
803/// data the target can't represent, defaults synthesized, or blocks mapped best
804/// effort. An empty `warnings` means a faithful conversion. For [`convert_file`]
805/// and [`convert_str`], `warnings` carries the read side ([`Parsed`] warnings)
806/// too, ahead of the write side.
807///
808/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
809/// never constructs it, leaving room to add fidelity metadata without a breaking
810/// change.
811#[derive(Debug, Clone)]
812#[non_exhaustive]
813pub struct Conversion {
814    pub text: String,
815    pub warnings: Vec<String>,
816}
817
818/// Optional write-time policies layered on top of the neutral [`BalancedNetwork`].
819///
820/// The default is a no-op and preserves the old `write_as` / `convert_*`
821/// behavior. Non-default options work on a cloned network and never mutate the
822/// caller's case.
823#[derive(Debug, Clone, Default)]
824pub struct WriteOptions {
825    pub missing_gen_cost: MissingGenCostPolicy,
826    pub gen_cost_patches: Vec<GenCostPatch>,
827}
828
829impl WriteOptions {
830    #[must_use]
831    pub fn is_default(&self) -> bool {
832        self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
833    }
834}
835
836/// Convert a [`BalancedNetwork`] to `format`. Writing back to the source format returns
837/// the retained source text; otherwise the network is serialized into the target.
838///
839/// # Errors
840/// Only a `PowerioJson` serialization failure. A non-finite value is not an
841/// error: readers can produce
842/// `Inf` limits and the bindings materialize every network through the
843/// snapshot, so it is written as `null` with a fidelity warning naming the
844/// field: that output serves the one-way transports but does not read back
845/// (the validating reader rejects the `null`).
846pub fn write_as(net: &BalancedNetwork, format: TargetFormat) -> Result<Conversion> {
847    if is_echo(net, format) {
848        if let Some(src) = &net.source {
849            return Ok(Conversion {
850                text: src.to_string(),
851                warnings: Vec::new(),
852            });
853        }
854    }
855    let mut conv = match format {
856        TargetFormat::PowerModelsJson => write_powermodels_json(net),
857        TargetFormat::EgretJson => write_egret_json(net),
858        TargetFormat::Psse { rev } => write_psse_rev(net, rev),
859        TargetFormat::PowerWorld => write_powerworld(net),
860        TargetFormat::PandapowerJson => write_pandapower_json(net),
861        // From another source (or no retained source): canonical MATPOWER from
862        // the folded model, which itemizes what it can't carry (HVDC, gen caps,
863        // extras, a partial-cost case).
864        TargetFormat::Matpower => matpower::write_matpower_conversion(net),
865        // The snapshot serializes the model itself, so the usual target
866        // passes don't apply (warn_normalized_tap would even be FALSE here:
867        // the snapshot preserves the line/transformer labels it warns about);
868        // return before them. The one fidelity loss the snapshot can suffer
869        // is JSON's missing Inf/NaN: serde writes them as `null`, which
870        // `from_json` rejects on the way back, so warn, naming every field.
871        TargetFormat::PowerioJson => {
872            return net.to_json().map(|text| Conversion {
873                text,
874                warnings: net
875                    .non_finite_fields()
876                    .into_iter()
877                    .map(|path| {
878                        format!(
879                            "{path} is not finite; JSON has no Inf/NaN, so it is written as \
880                             null and this snapshot will not read back as powerio-json"
881                        )
882                    })
883                    .collect(),
884            });
885        }
886        TargetFormat::Pslf => write_pslf(net),
887        TargetFormat::SurgeJson => write_surge_json(net),
888        TargetFormat::Goc3Json => {
889            return Err(Error::WriteUnsupported {
890                format: "goc3-json",
891            });
892        }
893        TargetFormat::DeepMindOpfDataJson => {
894            return Err(Error::WriteUnsupported {
895                format: "opfdata-json",
896            });
897        }
898    };
899    warn_normalized_tap(net, format, &mut conv);
900    warn_missing_reference(net, format, &mut conv);
901    warn_dropped_frequency(net, format, &mut conv);
902    warn_dropped_locations(net, format, &mut conv);
903    warn_psse_downgrade(net, format, &mut conv);
904    warn_dropped_transformer_charging(net, format, &mut conv);
905    Ok(conv)
906}
907
908/// Convert a [`BalancedNetwork`] with write-time cost policies. The old [`write_as`]
909/// behavior is preserved when `options` is default.
910pub fn write_as_with_options(
911    net: &BalancedNetwork,
912    format: TargetFormat,
913    options: &WriteOptions,
914) -> Result<Conversion> {
915    if options.is_default() {
916        return write_as(net, format);
917    }
918
919    let mut working = net.clone();
920    let report =
921        working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
922    let mut policy_warnings = Vec::new();
923    if report.patched > 0 {
924        policy_warnings.push(format!(
925            "generator cost patch applied to {} generator(s)",
926            report.patched
927        ));
928    }
929    if report.synthesized > 0 {
930        policy_warnings.push(match options.missing_gen_cost {
931            MissingGenCostPolicy::Fill {
932                c2,
933                c1,
934                c0,
935                startup,
936                shutdown,
937            } => format!(
938                "generator cost synthesized for {} generator(s): model 2, ncost 3, \
939                 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
940                report.synthesized
941            ),
942            _ => unreachable!("only Fill synthesizes costs"),
943        });
944    }
945    if report.patched > 0 || report.synthesized > 0 {
946        working.source = None;
947    }
948
949    let mut conv = write_as(&working, format)?;
950    policy_warnings.append(&mut conv.warnings);
951    conv.warnings = policy_warnings;
952    Ok(conv)
953}
954
955/// Allocate a circuit id for an element keyed by `key` — a bus for loads/shunts,
956/// or a `(from, to)` pair for branches: reuse the source-supplied `preferred` id
957/// when it is still free on this key, else the lowest free positional id. Keeps
958/// parallel devices distinct so the `(key, id)` uniqueness rule the PSS/E and
959/// PSLF records require holds even when the source supplies colliding ids.
960pub(super) fn allocate_circuit_id<K: Ord + Clone>(
961    preferred: Option<&str>,
962    key: K,
963    used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
964) -> String {
965    let taken = used.entry(key).or_default();
966    if let Some(id) = preferred {
967        if taken.insert(id.to_owned()) {
968            return id.to_owned();
969        }
970    }
971    let mut n = 1u32;
972    loop {
973        let candidate = n.to_string();
974        if taken.insert(candidate.clone()) {
975            return candidate;
976        }
977        n += 1;
978    }
979}
980
981/// Warn when a PSS/E source is re-serialized at an older revision than its own.
982/// `parse_file` maps every `.raw` to revision 33 and the `psse`/`raw` aliases
983/// resolve to 33, so writing a v34/v35 source through the default target skips
984/// the echo path (revisions differ) and re-emits the v33 layout, dropping the
985/// modern records (12 named ratings, load DG/LOADTYPE columns, the system-wide
986/// block) and any unmodeled section the echo would have preserved. Name the
987/// downgrade instead of performing it silently.
988fn warn_psse_downgrade(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
989    if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) =
990        (format, net.source_format, net.source.as_ref())
991    {
992        let src_rev = psse::header_rev(src);
993        if src_rev > rev {
994            conv.warnings.push(format!(
995                "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
996                 the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
997            ));
998        }
999    }
1000}
1001
1002/// Warn when a non-default system frequency writes to a format with no frequency
1003/// field. PSS/E (`BASFRQ`) and pandapower (`f_hz`) carry it; MATPOWER,
1004/// PowerModels, egret, and PowerWorld have nowhere to put it, so a 50 Hz case
1005/// would silently read back as the 60 Hz default. Report the loss instead.
1006fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1007    let carries_frequency = matches!(
1008        format,
1009        TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
1010    );
1011    if carries_frequency {
1012        return;
1013    }
1014    if (net.base_frequency - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1015        conv.warnings.push(format!(
1016            "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1017            net.base_frequency,
1018            format.label(),
1019            crate::network::DEFAULT_BASE_FREQUENCY
1020        ));
1021    }
1022}
1023
1024/// Warn when the case carries bus locations and the target has no geometry
1025/// concept. PowerWorld aux (`Latitude:1`/`Longitude:1`) and pandapower
1026/// (`geo`) carry them, and the PyPSA folder writer (`x`/`y`) has its own
1027/// path; MATPOWER, PSS/E, PowerModels, egret, PSLF, and Surge have nowhere to
1028/// put them, matching the `base_frequency` behavior. `powerio geo extract`
1029/// writes the sidecar as the escape hatch.
1030fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1031    let carries_locations = matches!(
1032        format,
1033        TargetFormat::PowerWorld | TargetFormat::PandapowerJson
1034    );
1035    if carries_locations {
1036        return;
1037    }
1038    let n = net.buses.iter().filter(|b| b.location.is_some()).count();
1039    let routed = net.branches.iter().filter(|b| b.route.is_some()).count();
1040    if n > 0 || routed > 0 {
1041        conv.warnings.push(format!(
1042            "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1043             coordinate field (write a .geo.json sidecar to keep them)",
1044            format.label()
1045        ));
1046    }
1047}
1048
1049/// Warn when a transformer carries line charging and the target's
1050/// transformer record has no susceptance column to hold it. The PSLF `.epc`
1051/// transformer record is the one such target; PSS/E writes representable
1052/// magnetizing admittance and the MATPOWER shaped writers keep the legacy total
1053/// projection on the branch row, so neither drops it.
1054fn warn_dropped_transformer_charging(
1055    net: &BalancedNetwork,
1056    format: TargetFormat,
1057    conv: &mut Conversion,
1058) {
1059    if !matches!(format, TargetFormat::Pslf) {
1060        return;
1061    }
1062    let n = net
1063        .branches
1064        .iter()
1065        .filter(|b| b.is_transformer() && b.total_charging_b() != 0.0)
1066        .count();
1067    if n > 0 {
1068        conv.warnings.push(format!(
1069            "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1070             record cannot represent; the charging was dropped"
1071        ));
1072    }
1073}
1074
1075pub(super) fn branch_rating_set_drop_warning(
1076    target: &str,
1077    branch_index: usize,
1078    branch: &Branch,
1079    rating: &BranchRatingSet,
1080) -> String {
1081    format!(
1082        "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1083        branch_index + 1,
1084        branch.from,
1085        branch.to,
1086        rating.name,
1087        rating.rate_mva,
1088        target
1089    )
1090}
1091
1092pub(super) fn warn_extra_branch_rating_sets(
1093    target: &str,
1094    net: &BalancedNetwork,
1095    warnings: &mut Vec<String>,
1096) {
1097    for (branch_index, branch) in net.branches.iter().enumerate() {
1098        for rating in &branch.rating_sets {
1099            warnings.push(branch_rating_set_drop_warning(
1100                target,
1101                branch_index,
1102                branch,
1103                rating,
1104            ));
1105        }
1106    }
1107}
1108
1109/// Convert a case file to `to`, optionally forcing the source format with
1110/// `from`.
1111///
1112/// This is the canonical file-conversion helper shared by the bindings. It
1113/// parses `path` once, writes the resulting [`BalancedNetwork`] to `to`, and returns the
1114/// converted text plus any fidelity warnings, read side first. An echo (writing
1115/// back to the source format) returns the retained text with no warnings.
1116///
1117/// # Errors
1118/// As [`parse_file`].
1119pub fn convert_file(
1120    path: impl AsRef<std::path::Path>,
1121    to: TargetFormat,
1122    from: Option<&str>,
1123) -> Result<Conversion> {
1124    let parsed = parse_file(path, from)?;
1125    let mut conv = write_as(&parsed.network, to)?;
1126    if !is_echo(&parsed.network, to) {
1127        conv.warnings.splice(0..0, parsed.warnings);
1128    }
1129    Ok(conv)
1130}
1131
1132/// Convert a case file with write-time cost policies.
1133pub fn convert_file_with_options(
1134    path: impl AsRef<std::path::Path>,
1135    to: TargetFormat,
1136    from: Option<&str>,
1137    options: &WriteOptions,
1138) -> Result<Conversion> {
1139    let parsed = parse_file(path, from)?;
1140    let mut conv = write_as_with_options(&parsed.network, to, options)?;
1141    if !is_echo(&parsed.network, to) || !options.is_default() {
1142        conv.warnings.splice(0..0, parsed.warnings);
1143    }
1144    Ok(conv)
1145}
1146
1147/// Convert in-memory case `text` of the named `format` (see
1148/// [`target_format_from_name`]) to `to`.
1149///
1150/// Parses `text` once and writes the resulting [`BalancedNetwork`] to `to` without a
1151/// temporary file. Warnings are ordered read side first, as in
1152/// [`convert_file`].
1153///
1154/// # Errors
1155/// As [`parse_str`].
1156pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
1157    let parsed = parse_str(text, format)?;
1158    let mut conv = write_as(&parsed.network, to)?;
1159    if !is_echo(&parsed.network, to) {
1160        conv.warnings.splice(0..0, parsed.warnings);
1161    }
1162    Ok(conv)
1163}
1164
1165/// Convert in-memory case text with write-time cost policies.
1166pub fn convert_str_with_options(
1167    text: &str,
1168    to: TargetFormat,
1169    format: &str,
1170    options: &WriteOptions,
1171) -> Result<Conversion> {
1172    let parsed = parse_str(text, format)?;
1173    let mut conv = write_as_with_options(&parsed.network, to, options)?;
1174    if !is_echo(&parsed.network, to) || !options.is_default() {
1175        conv.warnings.splice(0..0, parsed.warnings);
1176    }
1177    Ok(conv)
1178}
1179
1180/// Write `net` into `out_dir` as the named directory format. This function
1181/// dispatches directory format names for the bindings. PyPSA CSV
1182/// (`pypsa-csv`/`pypsa`) is the one such
1183/// format today; a text format name is rejected by name, pointing at
1184/// [`write_as`]. Returns the write's fidelity warnings.
1185///
1186/// # Errors
1187/// [`Error::UnknownFormat`] for a non-directory format name; the writer's own
1188/// [`Error`] otherwise.
1189pub fn write_dir(
1190    net: &BalancedNetwork,
1191    to: &str,
1192    out_dir: impl AsRef<std::path::Path>,
1193) -> Result<Vec<String>> {
1194    if is_pypsa_csv_name(to) {
1195        return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings);
1196    }
1197    Err(Error::UnknownFormat(format!(
1198        "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
1199         text formats serialize through write_as / to_format"
1200    )))
1201}
1202
1203/// Warn when a network with no reference (slack) bus converts to a format
1204/// whose solvers require one. PowerWorld `.pwb` is the one source that
1205/// systematically lacks the designation (the binary does not store it), so
1206/// the silent case would be common; `to_normalized` synthesizes a slack at
1207/// the largest pmax in service generator bus for consumers that need one.
1208fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1209    let needs_ref = matches!(
1210        format,
1211        TargetFormat::Matpower
1212            | TargetFormat::Psse { .. }
1213            | TargetFormat::PowerModelsJson
1214            | TargetFormat::PandapowerJson
1215            | TargetFormat::Pslf
1216            | TargetFormat::SurgeJson
1217    );
1218    if needs_ref {
1219        conv.warnings.extend(missing_reference_warning(net));
1220    }
1221}
1222
1223/// The slackless-network warning itself, shared with the PyPSA folder writer
1224/// (which produces `PypsaCsvOutputs`, not a [`Conversion`], so it cannot go
1225/// through [`warn_missing_reference`]).
1226pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
1227    (!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
1228        "no reference (slack) bus in the source network; power flow tools \
1229         reject such cases; to_normalized synthesizes a slack at the \
1230         largest pmax in service generator bus"
1231            .to_string()
1232    })
1233}
1234
1235/// A normalized network has its tap canonicalized to `1.0` on every line (the
1236/// `0 → 1` rule), but [`Branch::is_transformer`](crate::network::Branch::is_transformer),
1237/// the test these writers use to split lines from transformers, keys off
1238/// `tap != 0`. So a normalized line is written into the transformer section/type.
1239/// The power flow is identical (a unity-ratio, zero-shift transformer equals a
1240/// line), but the label is not, so report the fidelity loss rather than relabel
1241/// it silently. MATPOWER has no separate transformer representation (just a `TAP`
1242/// column), so it is exempt.
1243// `tap == 1.0` / `shift == 0.0` are exact by construction: normalization sets a
1244// line's tap from `effective_tap()` (the literal `1.0`) and its shift from
1245// `0.0 * DEG_TO_RAD` (exactly `0.0`), so an epsilon compare would be wrong here.
1246#[allow(clippy::float_cmp)]
1247fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1248    if matches!(format, TargetFormat::Matpower) {
1249        return;
1250    }
1251    conv.warnings.extend(normalized_tap_warning(net));
1252}
1253
1254/// The normalized-label warning itself, shared with the PyPSA folder writer.
1255// `tap == 1.0` / `shift == 0.0` are exact by construction (see
1256// `warn_normalized_tap`), so an epsilon compare would be wrong here.
1257#[allow(clippy::float_cmp)]
1258pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
1259    if !net.is_normalized() {
1260        return None;
1261    }
1262    // After normalization a line (raw tap 0) and a unity-ratio transformer (raw
1263    // tap 1) both read as tap 1.0 / shift 0.0, so they cannot be told apart. Count
1264    // them together as the branches whose line/transformer label is now ambiguous.
1265    let ambiguous = net
1266        .branches
1267        .iter()
1268        .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1269        .count();
1270    (ambiguous > 0).then(|| {
1271        format!(
1272            "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1273             shift, so the line/transformer label is not preserved (the power flow \
1274             is identical)"
1275        )
1276    })
1277}
1278
1279/// True when `value` is set and deviates from `reference`: the shared test for
1280/// "does this rating column carry information the target cannot" used by the
1281/// rate_b/rate_c drop warnings.
1282fn nonzero_differs(value: f64, reference: f64) -> bool {
1283    value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1284}
1285
1286/// Set a bus's kind through the `bus_pos` index, leaving Isolated buses alone.
1287/// Shared by the readers that derive bus kinds from generator/slack tables.
1288pub(crate) fn set_bus_kind(
1289    buses: &mut [Bus],
1290    bus_pos: &HashMap<BusId, usize>,
1291    bus: BusId,
1292    kind: BusType,
1293) {
1294    if let Some(&idx) = bus_pos.get(&bus) {
1295        if buses[idx].kind != BusType::Isolated {
1296            buses[idx].kind = kind;
1297        }
1298    }
1299}
1300
1301/// `base_kv` of a bus through the `bus_pos` index; 0.0 for an unknown bus.
1302pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1303    bus_pos
1304        .get(&bus)
1305        .and_then(|&i| buses.get(i))
1306        .map_or(0.0, |b| b.base_kv)
1307}
1308
1309/// Replace characters that would corrupt a quoted or delimited field with
1310/// `replacement`, so a free-form name can't shift or truncate the record it sits
1311/// in. `forbidden` lists the destination's quote, delimiter, and comment chars.
1312/// Returns the value borrowed unchanged when it holds none of them, so the common
1313/// clean-name path allocates nothing.
1314///
1315/// Each text writer calls this at its quoting seam and warns when the result
1316/// differs from the input (the substitution silently alters operator-facing
1317/// names): the PSS/E single-quoted bus name and the PowerWorld double-quoted bus
1318/// name both interpolate a `BalancedNetwork` name straight into a quoted field, where an
1319/// embedded quote (or, for PSS/E, the `/` inline-comment delimiter) would shift
1320/// every later column of the record.
1321/// A line terminator is always replaced, whatever `forbidden` holds: no text
1322/// record format can carry one inside a field, so an embedded `\n` does not
1323/// shift a column, it ends the record and makes everything after it parse as
1324/// a new one. A crafted name could otherwise forge whole records in the
1325/// written file.
1326pub(crate) fn sanitize_quoted<'a>(
1327    value: &'a str,
1328    forbidden: &[char],
1329    replacement: char,
1330) -> std::borrow::Cow<'a, str> {
1331    let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
1332    if value.contains(breaks) {
1333        value
1334            .chars()
1335            .map(|c| if breaks(c) { replacement } else { c })
1336            .collect::<String>()
1337            .into()
1338    } else {
1339        std::borrow::Cow::Borrowed(value)
1340    }
1341}
1342
1343/// Impedance base `v_kv² / base_mva`; 1.0 when either base is missing, so a
1344/// per-unit ↔ ohm conversion on it is the identity.
1345pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1346    if v_kv > 0.0 && base_mva > 0.0 {
1347        v_kv * v_kv / base_mva
1348    } else {
1349        1.0
1350    }
1351}
1352
1353/// Whether writing `net` to `target` echoes the retained source text: the
1354/// target is the source format and the source is still attached. An echo
1355/// reproduces the input byte for byte, so read fidelity warnings don't apply.
1356fn is_echo(net: &BalancedNetwork, target: TargetFormat) -> bool {
1357    let Some(src) = &net.source else { return false };
1358    if !same_format(target, net.source_format) {
1359        return false;
1360    }
1361    // A PSS/E source echoes only when the requested revision equals the source's
1362    // own; any other revision must go through write_psse_rev so the caller gets
1363    // the layout it asked for instead of the original bytes.
1364    if let TargetFormat::Psse { rev } = target {
1365        return psse::header_rev(src) == rev;
1366    }
1367    true
1368}
1369
1370/// Whether a write target is the same format the network was read from.
1371fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1372    matches!(
1373        (target, source),
1374        (TargetFormat::Matpower, SourceFormat::Matpower)
1375            | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1376            | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1377            | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1378            | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1379            | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1380            | (TargetFormat::Pslf, SourceFormat::Pslf)
1381            | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1382            | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1383            | (
1384                TargetFormat::DeepMindOpfDataJson,
1385                SourceFormat::DeepMindOpfDataJson,
1386            )
1387    )
1388}
1389
1390/// JSON number for a finite `f64`; `Value::Null` for `NaN`/`±Inf`.
1391pub(crate) fn jnum(x: f64) -> Value {
1392    serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1393}
1394
1395/// Serialize a built JSON tree into a [`Conversion`], appending one warning that
1396/// names every field where a non-finite `f64` was written as `null` (JSON has no
1397/// `±Inf`/`NaN`). Shared by the JSON writers.
1398pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
1399    let value = Value::Object(root);
1400    let mut nulls = BTreeSet::new();
1401    collect_null_keys(&value, &mut nulls);
1402    if !nulls.is_empty() {
1403        warnings.push(format!(
1404            "non-finite numeric values written as JSON null in field(s): {}",
1405            nulls.into_iter().collect::<Vec<_>>().join(", ")
1406        ));
1407    }
1408    let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1409    Conversion { text, warnings }
1410}
1411
1412/// Collect the names of object keys whose value is `null`, anywhere in the tree.
1413fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1414    match value {
1415        Value::Object(map) => {
1416            for (key, val) in map {
1417                if val.is_null() {
1418                    out.insert(key.clone());
1419                } else {
1420                    collect_null_keys(val, out);
1421                }
1422            }
1423        }
1424        Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1425        _ => {}
1426    }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432    use crate::network::SourceFormat;
1433
1434    #[test]
1435    fn sanitize_quoted_always_replaces_line_terminators() {
1436        // A terminator ends the record, so it is replaced whatever the
1437        // caller's delimiter set holds: a name carrying one could otherwise
1438        // forge whole records in a written .raw/.aux/.epc.
1439        for forbidden in [&[][..], &['\''][..], &['"'][..]] {
1440            let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
1441            assert!(
1442                !out.contains('\n') && !out.contains('\r'),
1443                "terminator survived with forbidden={forbidden:?}: {out:?}"
1444            );
1445        }
1446        // A clean value is still borrowed, not copied.
1447        assert!(matches!(
1448            sanitize_quoted("clean name", &['\''], ' '),
1449            std::borrow::Cow::Borrowed(_)
1450        ));
1451    }
1452
1453    #[test]
1454    fn dss_extension_error_names_the_distribution_surface() {
1455        let err = parse_file("feeder.dss", None).unwrap_err();
1456        assert!(err.to_string().contains("distribution"), "got: {err}");
1457    }
1458
1459    #[test]
1460    fn distribution_from_token_error_names_the_distribution_surface() {
1461        for token in ["dss", "pmd", "bmopf"] {
1462            let err = parse_str("anything", token).unwrap_err();
1463            assert!(
1464                err.to_string().contains("distribution surface"),
1465                "{token}: {err}"
1466            );
1467        }
1468        // A genuinely unknown token still echoes plainly.
1469        let err = parse_str("anything", "nonesuch").unwrap_err();
1470        assert!(err.to_string().contains("nonesuch"));
1471    }
1472
1473    #[test]
1474    fn byte_order_mark_is_stripped_and_warned() {
1475        let case = "\u{feff}function mpc = t\n\
1476                    mpc.version = '2';\n\
1477                    mpc.baseMVA = 100;\n\
1478                    mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
1479                    mpc.gen = [];\n\
1480                    mpc.branch = [];\n";
1481        let parsed = parse_str(case, "matpower").unwrap();
1482        assert_eq!(parsed.network.buses.len(), 1);
1483        assert!(
1484            parsed
1485                .warnings
1486                .iter()
1487                .any(|w| w.contains("byte order mark")),
1488            "warnings: {:?}",
1489            parsed.warnings
1490        );
1491    }
1492
1493    #[test]
1494    fn package_json_error_names_the_package_reader() {
1495        let err = sniff_json(r#"{"model_kind":"balanced","model":{}}"#).unwrap_err();
1496        assert!(err.to_string().contains(".pio.json"), "got: {err}");
1497    }
1498
1499    #[test]
1500    fn source_format_strings_round_trip_to_a_target() {
1501        // The bindings expose `source_format` as its `{:?}` form, and
1502        // `to_format` routes that string back through `target_format_from_name`.
1503        // Every writable source format must resolve, including PowerModelsJson /
1504        // EgretJson, whose camel-case names need the `powermodelsjson` /
1505        // `egretjson` aliases (issue #75).
1506        for (sf, want) in [
1507            (SourceFormat::Matpower, TargetFormat::Matpower),
1508            (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
1509            (SourceFormat::EgretJson, TargetFormat::EgretJson),
1510            (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
1511            (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
1512            (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
1513            (SourceFormat::Pslf, TargetFormat::Pslf),
1514            (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
1515            (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
1516            (
1517                SourceFormat::DeepMindOpfDataJson,
1518                TargetFormat::DeepMindOpfDataJson,
1519            ),
1520        ] {
1521            let token = format!("{sf:?}");
1522            assert_eq!(
1523                target_format_from_name(&token),
1524                Some(want),
1525                "source_format {token:?} did not round-trip"
1526            );
1527        }
1528        // The derived/in-memory source formats have no writer target, and
1529        // neither does the read only .pwb binary.
1530        for sf in [
1531            SourceFormat::InMemory,
1532            SourceFormat::Normalized,
1533            SourceFormat::Gridfm,
1534            SourceFormat::PypsaCsv,
1535            SourceFormat::PowerWorldBinary,
1536        ] {
1537            assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
1538        }
1539    }
1540}