Skip to main content

powerio/format/
mod.rs

1//! Readers and writers for supported case formats, all meeting at [`Network`].
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. PowerWorld `.pwb` cases, GO Challenge 3 JSON canonical output,
7//! and PowerWorld `.pwd` displays are read only. Case input and
8//! output formats meet here, so adding a writable format is one module plus
9//! one hub registration.
10//! [`parse_file`] reads Network cases, detecting the format from its extension;
11//! [`parse_display_file`] reads display artifacts such as PowerWorld `.pwd`.
12//! [`write_as`] serializes a `Network` to text targets. Directory formats,
13//! such as PyPSA CSV folders, use explicit filesystem helpers. Non-finite
14//! numeric values, such as MATPOWER `Inf`/`NaN` angle limits, are written as
15//! JSON `null`.
16//!
17//! # Fidelity behavior
18//!
19//! Conversion is two-tier:
20//!
21//! - **Same format writes return the original text.** A reader keeps its source
22//!   text (see [`Network`]), so writing back to the same format returns every
23//!   field, comment, and numeric token.
24//! - **Cross-format keeps maximal fidelity with itemized loss.** Whatever the
25//!   target format cannot represent is reported in the [`Conversion`] `warnings`,
26//!   never dropped silently. On the read side, readers itemize what they ignore
27//!   in [`Parsed`] `warnings`.
28
29use std::collections::{BTreeSet, HashMap};
30use std::fmt;
31use std::str::FromStr;
32use std::sync::Arc;
33
34use serde_json::{Map, Value};
35
36use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
37use crate::network::{Branch, BranchRatingSet, Bus, BusId, BusType, Network, SourceFormat};
38use crate::{Error, Result};
39use routing::{Detection, SourceFormat as DetectedFormat, TransmissionFormat};
40
41mod egret;
42mod goc3;
43mod matpower;
44mod pandapower;
45mod powermodels;
46pub mod powerworld;
47mod pslf;
48mod psse;
49mod pypsa;
50pub mod routing;
51mod surge;
52
53pub use egret::{parse_egret_json, write_egret_json};
54#[doc(hidden)]
55pub use goc3::bridge as goc3_bridge;
56pub use goc3::parse_goc3_json;
57pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
58pub use pandapower::{parse_pandapower_json, write_pandapower_json};
59pub use powermodels::{parse_powermodels_json, write_powermodels_json};
60pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
61pub use pslf::{parse_pslf, write_pslf};
62pub use psse::{parse_psse, write_psse, write_psse_rev};
63pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
64pub use surge::{parse_surge_json, write_surge_json};
65
66/// A target interchange format. See [`write_as`].
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum TargetFormat {
70    /// PowerModels.jl network data JSON.
71    PowerModelsJson,
72    /// egret `ModelData` JSON.
73    EgretJson,
74    /// PSS/E `.raw` at the given revision. `rev` selects the record layout the
75    /// writer emits (33, 34, or 35); 33 is the historical default. The reader
76    /// takes the revision from the file header, so this only affects writes.
77    Psse { rev: u32 },
78    /// PowerWorld auxiliary `.aux`.
79    PowerWorld,
80    /// pandapower `pandapowerNet` JSON.
81    PandapowerJson,
82    /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source).
83    Matpower,
84    /// The canonical PowerIO snapshot: [`Network`] serialized as JSON, validated
85    /// on read. Lossless for every model field; the retained source text is the
86    /// one exclusion (see [`Network::to_json`]).
87    PowerioJson,
88    /// GE PSLF `.epc` (round-trip; byte-exact when the case kept its source).
89    Pslf,
90    /// ARPA-E GO Challenge 3 JSON input data. This is read only except for
91    /// same format source echo when the parsed network still carries its source.
92    Goc3Json,
93    /// Surge native JSON network document.
94    SurgeJson,
95}
96
97impl TargetFormat {
98    /// Conventional file extension for this format (no leading dot).
99    #[must_use]
100    pub fn extension(self) -> &'static str {
101        match self {
102            TargetFormat::PowerModelsJson
103            | TargetFormat::EgretJson
104            | TargetFormat::PandapowerJson
105            | TargetFormat::PowerioJson
106            | TargetFormat::Goc3Json
107            | TargetFormat::SurgeJson => "json",
108            TargetFormat::Psse { .. } => "raw",
109            TargetFormat::PowerWorld => "aux",
110            TargetFormat::Matpower => "m",
111            TargetFormat::Pslf => "epc",
112        }
113    }
114
115    /// Human-readable format name for diagnostics.
116    #[must_use]
117    pub fn label(self) -> &'static str {
118        match self {
119            TargetFormat::PowerModelsJson => "PowerModels JSON",
120            TargetFormat::EgretJson => "egret JSON",
121            TargetFormat::Psse { .. } => "PSS/E .raw",
122            TargetFormat::PowerWorld => "PowerWorld .aux",
123            TargetFormat::PandapowerJson => "pandapower JSON",
124            TargetFormat::Matpower => "MATPOWER .m",
125            TargetFormat::PowerioJson => "PowerIO JSON",
126            TargetFormat::Pslf => "PSLF .epc",
127            TargetFormat::Goc3Json => "GO Challenge 3 JSON",
128            TargetFormat::SurgeJson => "Surge JSON",
129        }
130    }
131
132    /// Canonical API token for this format.
133    #[must_use]
134    pub fn token(self) -> &'static str {
135        match self {
136            TargetFormat::PowerModelsJson => "powermodels-json",
137            TargetFormat::EgretJson => "egret-json",
138            TargetFormat::Psse { rev: 34 } => "psse34",
139            TargetFormat::Psse { rev: 35 } => "psse35",
140            TargetFormat::Psse { .. } => "psse",
141            TargetFormat::PowerWorld => "powerworld",
142            TargetFormat::PandapowerJson => "pandapower-json",
143            TargetFormat::Matpower => "matpower",
144            TargetFormat::PowerioJson => "powerio-json",
145            TargetFormat::Pslf => "pslf",
146            TargetFormat::Goc3Json => "goc3-json",
147            TargetFormat::SurgeJson => "surge-json",
148        }
149    }
150}
151
152impl fmt::Display for TargetFormat {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        f.write_str(self.token())
155    }
156}
157
158impl FromStr for TargetFormat {
159    type Err = Error;
160
161    fn from_str(name: &str) -> Result<Self> {
162        target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
163    }
164}
165
166/// A display artifact format. These files are not power network cases and do
167/// not parse to [`Network`].
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169#[non_exhaustive]
170pub enum DisplayFormat {
171    /// PowerWorld oneline display `.pwd`.
172    PowerWorld,
173}
174
175impl DisplayFormat {
176    /// Conventional file extension for this display format (no leading dot).
177    #[must_use]
178    pub fn extension(self) -> &'static str {
179        match self {
180            DisplayFormat::PowerWorld => "pwd",
181        }
182    }
183
184    /// Human-readable format name for diagnostics.
185    #[must_use]
186    pub fn label(self) -> &'static str {
187        match self {
188            DisplayFormat::PowerWorld => "PowerWorld .pwd",
189        }
190    }
191
192    /// Canonical API token for this format.
193    #[must_use]
194    pub fn token(self) -> &'static str {
195        match self {
196            DisplayFormat::PowerWorld => "powerworld-display",
197        }
198    }
199}
200
201impl fmt::Display for DisplayFormat {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(self.token())
204    }
205}
206
207impl FromStr for DisplayFormat {
208    type Err = Error;
209
210    fn from_str(name: &str) -> Result<Self> {
211        display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
212    }
213}
214
215/// Map a display format name to a [`DisplayFormat`], or `None` if unrecognized.
216/// Accepts `pwd`, `powerworld-pwd`, and `powerworld-display`.
217#[must_use]
218pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
219    Some(match name.to_ascii_lowercase().as_str() {
220        "pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
221        _ => return None,
222    })
223}
224
225/// Map a format name (with the common aliases) to a [`TargetFormat`], or `None`
226/// if unrecognized. Accepts `matpower`/`m`, `powermodels-json`/`powermodels`/`pm`,
227/// `egret-json`/`egret`, `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`,
228/// `powerworld`/`aux`, `powerio-json`/`powerio`/`json` (the canonical snapshot;
229/// plain `json` means this one, the foreign JSON dialects are namespaced),
230/// `pslf`/`epc`, `goc3-json`/`goc3`, and `surge-json`/`surge`.
231/// Case-insensitive. The one place the bindings (Python, C ABI) share, so a new
232/// text format means one new arm here, not three. PyPSA CSV folders, GridFM
233/// datasets, and PowerWorld `.pwb` are directory or read only inputs with no
234/// text target; they are routed by [`crate::format::routing`].
235///
236/// The `powermodelsjson`/`egretjson`/`pandapowerjson` aliases let a
237/// [`SourceFormat`]'s string form (`{:?}` lowercased, e.g. `"PowerModelsJson"`)
238/// round-trip back to a target, so `net.to_format(other.source_format)` works
239/// for every format.
240#[must_use]
241pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
242    Some(match routing::transmission_format_from_name(name)? {
243        TransmissionFormat::Matpower => TargetFormat::Matpower,
244        TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
245        TransmissionFormat::EgretJson => TargetFormat::EgretJson,
246        TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
247        TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
248        TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
249        TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
250        TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
251        TransmissionFormat::PowerioJson => TargetFormat::PowerioJson,
252        TransmissionFormat::Pslf => TargetFormat::Pslf,
253        TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
254        TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
255        TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
256            return None;
257        }
258    })
259}
260
261/// Output of a display parse. v0.2.2 supports PowerWorld `.pwd`; future display
262/// formats can add variants without changing the parse entry point.
263#[derive(Debug, Clone, PartialEq)]
264#[non_exhaustive]
265pub enum DisplayData {
266    /// PowerWorld oneline display data.
267    PowerWorld(PwdDisplay),
268}
269
270impl DisplayData {
271    /// The display format represented by this value.
272    #[must_use]
273    pub fn format(&self) -> DisplayFormat {
274        match self {
275            DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
276        }
277    }
278}
279
280fn display_file_guidance() -> Error {
281    Error::UnknownFormat(
282        "a PowerWorld .pwd is display data, not a Network case; \
283         use parse_display_file(path, None)"
284            .into(),
285    )
286}
287
288/// Parse display bytes in the named display `format`.
289///
290/// # Errors
291/// [`Error::UnknownFormat`] if `format` is not a display format; otherwise the
292/// reader's own [`Error`] on malformed input.
293pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
294    let fmt =
295        display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
296    match fmt {
297        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
298            bytes,
299        )?)),
300    }
301}
302
303/// Parse the display file at `path`, choosing the reader from `from` or, when
304/// `None`, from the extension. v0.2.2 infers PowerWorld `.pwd`.
305///
306/// # Errors
307/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension cannot
308/// be mapped; [`Error::Io`] if the file cannot be read; the reader's own
309/// [`Error`] on malformed input.
310pub fn parse_display_file(
311    path: impl AsRef<std::path::Path>,
312    from: Option<&str>,
313) -> Result<DisplayData> {
314    let path = path.as_ref();
315    let fmt = match from {
316        Some(f) => {
317            display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
318        }
319        None => match path
320            .extension()
321            .and_then(|e| e.to_str())
322            .map(str::to_ascii_lowercase)
323            .as_deref()
324        {
325            Some("pwd") => DisplayFormat::PowerWorld,
326            other => {
327                return Err(Error::UnknownFormat(format!(
328                    "cannot infer display format from file extension {other:?}; \
329                     pass an explicit display format"
330                )));
331            }
332        },
333    };
334    let bytes = std::fs::read(path)?;
335    match fmt {
336        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
337            &bytes,
338        )?)),
339    }
340}
341
342/// Whether a format name means a PyPSA CSV folder. PyPSA folders are directory
343/// inputs, not text targets, so they have no [`TargetFormat`] arm; this is the
344/// companion alias matcher to [`target_format_from_name`] and the one place the
345/// PyPSA aliases live.
346fn is_pypsa_csv_name(name: &str) -> bool {
347    matches!(
348        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
349        "pypsacsv" | "pypsa"
350    )
351}
352
353/// Whether a source format name means PSLF EPC.
354fn is_pslf_name(name: &str) -> bool {
355    matches!(
356        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
357        "pslf" | "epc" | "pslfepc"
358    )
359}
360
361/// Parse the case file at `path`, choosing the reader from `from` (the
362/// [`target_format_from_name`] names plus `pypsa-csv`/`pypsa`, `pwb`, `pslf`,
363/// and `epc`) or, when `None`, from the path: a directory containing
364/// `network.csv` parses as a PyPSA CSV folder (any other directory fails:
365/// [`Error::UnknownFormat`] when its name maps to no extension, the I/O error
366/// otherwise), and a file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`/`epc`),
367/// case insensitively (issue #97: `.RAW` is as common as `.raw` in the wild). A
368/// `.json` file is classified by top level shape markers: pandapower
369/// (`"_class": "pandapowerNet"`), egret (`elements` and `system`), GO Challenge
370/// 3 (`network` plus `time_series_input`/`reliability`), Surge JSON
371/// (`format: "surge-json"`), powerio-json (`buses` plus network keys), and
372/// PowerModels JSON (`baseMVA`, `branch`, `gen`, or `gencost`). JSON matching
373/// distribution markers, ambiguous markers, or no known markers returns
374/// [`Error::UnknownFormat`]. Pass `from` to force a
375/// transmission format. PowerWorld `.pwb` is a binary read only format with no
376/// retained source; PSLF `.epc` is text and has a writer. Returns [`Parsed`]:
377/// the network plus the reader's fidelity warnings.
378///
379/// The one path-based parser the CLI and the Python/C/Julia bindings share (each
380/// exposes the same `parse_file(path, from)` shape), so adding a source format is
381/// one edit here, not one per binding. For in-memory text use [`parse_str`].
382///
383/// # Errors
384/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension can't be
385/// mapped; [`Error::Io`] if the file can't be read; the reader's own [`Error`]
386/// on malformed input.
387pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
388    let path = path.as_ref();
389    // PyPSA CSV folders are directories, not files; dispatch them before any
390    // extension logic. `from` accepts the pypsa aliases, and a bare directory
391    // with a `network.csv` auto-detects.
392    if from.is_some_and(is_pypsa_csv_name)
393        || (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
394    {
395        return pypsa::read_pypsa_csv_folder(path);
396    }
397    // PowerWorld `.pwb` is binary and read only; dispatch it before the text
398    // read. `from` accepts "pwb" for files with a different extension.
399    let ext = path
400        .extension()
401        .and_then(|e| e.to_str())
402        .map(str::to_ascii_lowercase);
403    if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
404        || (from.is_none() && ext.as_deref() == Some("pwb"))
405    {
406        let bytes = std::fs::read(path)?;
407        let stem = path.file_stem().and_then(|s| s.to_str());
408        // The binary reader is total (no fidelity warnings); wrap its network
409        // in the shared [`Parsed`] shape.
410        let network = powerworld::parse_pwb(&bytes, stem)?;
411        return Ok(Parsed {
412            network,
413            warnings: Vec::new(),
414        });
415    }
416    if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
417        let text = std::fs::read_to_string(path)?;
418        let stem = path.file_stem().and_then(|s| s.to_str());
419        let mut warnings = Vec::new();
420        let network = pslf::parse_pslf_source(Arc::new(text), stem, &mut warnings)?;
421        reject_empty_case(&network, "PSLF .epc")?;
422        return Ok(Parsed { network, warnings });
423    }
424    // Settle the format before touching the file: an unmapped or binary
425    // extension must surface as UnknownFormat, not as the UTF-8 read error
426    // the text formats' loader would hit first. `.pwd` gets its own arm
427    // because the display sibling ships next to every case file in the wild
428    // and carries no case data.
429    if from.is_none() && ext.as_deref() == Some("pwd") {
430        return Err(display_file_guidance());
431    }
432    let fmt_hint = match from {
433        Some(f) => {
434            if display_format_from_name(f).is_some() {
435                return Err(display_file_guidance());
436            }
437            Some(target_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?)
438        }
439        None => {
440            // Everything but `.json` (sniffed below) resolves without the text.
441            match ext.as_deref() {
442                Some("m") => Some(TargetFormat::Matpower),
443                Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
444                Some("aux") => Some(TargetFormat::PowerWorld),
445                Some("json") => None,
446                other => {
447                    return Err(Error::UnknownFormat(format!(
448                        "cannot infer from file extension {other:?}; \
449                         pass an explicit source format"
450                    )));
451                }
452            }
453        }
454    };
455    // Read the file once into an owned buffer; the reader moves it straight into
456    // the retained source (byte-exact round-trip) with no copy. Sniffing a
457    // `.json` borrows the text before the move.
458    let text = std::fs::read_to_string(path)?;
459    let fmt = match fmt_hint {
460        Some(fmt) => fmt,
461        None => sniff_json(&text)?,
462    };
463    // The file stem is the name hint for formats that don't carry their own name.
464    let stem = path.file_stem().and_then(|s| s.to_str());
465    read_source(Arc::new(text), fmt, stem)
466}
467
468/// Read an owned `source` buffer as `fmt`, using `name_hint` (e.g. the file
469/// stem) when the format carries no name of its own. The single format→reader
470/// map: [`parse_file`] and [`parse_str`] both funnel through it, so every format
471/// is dispatched the same way. Each reader takes the owned `Arc` so
472/// it moves the buffer straight into the retained source (no copy) and is free
473/// to specialize its parse internally. Owns the [`Parsed`] warnings vector;
474/// readers that report fidelity loss append to it.
475fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
476    let mut warnings = Vec::new();
477    let net = match fmt {
478        TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
479        TargetFormat::PowerModelsJson => {
480            powermodels::parse_powermodels_json_source(source, name_hint, &mut warnings)
481        }
482        TargetFormat::Psse { .. } => psse::parse_psse_source(source, name_hint, &mut warnings),
483        TargetFormat::PowerWorld => {
484            powerworld::parse_powerworld_source(source, name_hint, &mut warnings)
485        }
486        TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
487        TargetFormat::PandapowerJson => {
488            pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
489        }
490        // The canonical snapshot: validated deserialization of the model itself.
491        // It carries its own name and source_format, so the hint doesn't apply.
492        TargetFormat::PowerioJson => Network::from_json(&source),
493        // PSLF read normally enters through the `is_pslf_name`/`.epc` fast path in
494        // parse_file / parse_str; this arm keeps the funnel total.
495        TargetFormat::Pslf => pslf::parse_pslf_source(source, name_hint, &mut warnings),
496        TargetFormat::Goc3Json => goc3::parse_goc3_source(source, name_hint, &mut warnings),
497        TargetFormat::SurgeJson => surge::parse_surge_source(source, name_hint, &mut warnings),
498    }?;
499    reject_empty_case(&net, fmt.label())?;
500    Ok(Parsed {
501        network: net,
502        warnings,
503    })
504}
505
506/// A case with no buses is content-free for every consumer. Most readers
507/// already reject it on a missing required table, but a JSON carrying only
508/// `baseMVA` would otherwise parse to a hollow network; reject it in the
509/// [`read_source`] funnel so every parse path (file and in-memory) is guarded,
510/// and in the PyPSA folder reader, which bypasses the funnel.
511pub(crate) fn reject_empty_case(net: &Network, format: &'static str) -> Result<()> {
512    if net.buses.is_empty() {
513        return Err(Error::FormatRead {
514            format,
515            message: "case has no buses".into(),
516        });
517    }
518    Ok(())
519}
520
521/// The JSON formats share the `.json` extension, so an explicit source format
522/// isn't always given. Classification lives here so the CLI and bindings use
523/// the same top level markers as the Rust parsers.
524fn sniff_json(text: &str) -> Result<TargetFormat> {
525    match routing::classify_json_text(text) {
526        Detection::Known(DetectedFormat::Transmission(format)) => transmission_json_target(format),
527        Detection::Known(DetectedFormat::Distribution(format)) => {
528            Err(Error::UnknownFormat(format!(
529                "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
530                format.name()
531            )))
532        }
533        Detection::Ambiguous => Err(Error::UnknownFormat(
534            "ambiguous JSON markers; pass an explicit source format".into(),
535        )),
536        Detection::Unknown => Err(Error::UnknownFormat(
537            "cannot infer JSON format; pass an explicit source format".into(),
538        )),
539    }
540}
541
542fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
543    match format {
544        TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
545        TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
546        TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
547        TransmissionFormat::PowerioJson => Ok(TargetFormat::PowerioJson),
548        TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
549        TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
550        other => Err(Error::UnknownFormat(format!(
551            "JSON classifier returned non-JSON transmission format `{}`",
552            other.name()
553        ))),
554    }
555}
556
557/// Parse in-memory case `text` of the named `format` (see
558/// [`target_format_from_name`]). Returns [`Parsed`]: the network plus the
559/// reader's fidelity warnings.
560///
561/// # Errors
562/// [`Error::UnknownFormat`] if `format` is unrecognized; the reader's own
563/// [`Error`] on malformed input.
564pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
565    if is_pslf_name(format) {
566        let mut warnings = Vec::new();
567        let network = pslf::parse_pslf_source(Arc::new(text.to_owned()), None, &mut warnings)?;
568        reject_empty_case(&network, "PSLF .epc")?;
569        return Ok(Parsed { network, warnings });
570    }
571    let fmt =
572        target_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
573    read_source(Arc::new(text.to_owned()), fmt, None)
574}
575
576/// Output of a parse: the network plus the reader's fidelity warnings,
577/// tables and columns the model cannot carry, reported instead of dropped
578/// silently. Empty for readers that don't report read warnings (currently
579/// readers that do not need to reduce any source fields).
580///
581/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
582/// never constructs it, leaving room to add parse metadata without a breaking
583/// change.
584#[derive(Debug, Clone)]
585#[non_exhaustive]
586pub struct Parsed {
587    pub network: Network,
588    pub warnings: Vec<String>,
589}
590
591/// Output of a conversion: the serialized text plus any fidelity warnings:
592/// data the target can't represent, defaults synthesized, or blocks mapped best
593/// effort. An empty `warnings` means a faithful conversion. For [`convert_file`]
594/// and [`convert_str`], `warnings` carries the read side ([`Parsed`] warnings)
595/// too, ahead of the write side.
596///
597/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
598/// never constructs it, leaving room to add fidelity metadata without a breaking
599/// change.
600#[derive(Debug, Clone)]
601#[non_exhaustive]
602pub struct Conversion {
603    pub text: String,
604    pub warnings: Vec<String>,
605}
606
607/// Optional write-time policies layered on top of the neutral [`Network`].
608///
609/// The default is a no-op and preserves the old `write_as` / `convert_*`
610/// behavior. Non-default options work on a cloned network and never mutate the
611/// caller's case.
612#[derive(Debug, Clone, Default)]
613pub struct WriteOptions {
614    pub missing_gen_cost: MissingGenCostPolicy,
615    pub gen_cost_patches: Vec<GenCostPatch>,
616}
617
618impl WriteOptions {
619    #[must_use]
620    pub fn is_default(&self) -> bool {
621        self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
622    }
623}
624
625/// Convert a [`Network`] to `format`. Writing back to the source format returns
626/// the retained source text; otherwise the network is serialized into the target.
627///
628/// # Errors
629/// Only a `PowerioJson` serialization failure (none arise from this model
630/// today). A non-finite value is not an error: readers legitimately produce
631/// `Inf` limits and the bindings materialize every network through the
632/// snapshot, so it is written as `null` with a fidelity warning naming the
633/// field: that output serves the one-way transports but does not read back
634/// (the validating reader rejects the `null`).
635pub fn write_as(net: &Network, format: TargetFormat) -> Result<Conversion> {
636    if is_echo(net, format) {
637        if let Some(src) = &net.source {
638            return Ok(Conversion {
639                text: src.to_string(),
640                warnings: Vec::new(),
641            });
642        }
643    }
644    let mut conv = match format {
645        TargetFormat::PowerModelsJson => write_powermodels_json(net),
646        TargetFormat::EgretJson => write_egret_json(net),
647        TargetFormat::Psse { rev } => write_psse_rev(net, rev),
648        TargetFormat::PowerWorld => write_powerworld(net),
649        TargetFormat::PandapowerJson => write_pandapower_json(net),
650        // From another source (or no retained source): canonical MATPOWER from
651        // the folded model, which itemizes what it can't carry (HVDC, gen caps,
652        // extras, a partial-cost case).
653        TargetFormat::Matpower => matpower::write_matpower_conversion(net),
654        // The snapshot serializes the model itself, so the usual target
655        // passes don't apply (warn_normalized_tap would even be FALSE here:
656        // the snapshot preserves the line/transformer labels it warns about);
657        // return before them. The one fidelity loss the snapshot can suffer
658        // is JSON's missing Inf/NaN: serde writes them as `null`, which
659        // `from_json` rejects on the way back, so warn, naming every field.
660        TargetFormat::PowerioJson => {
661            return net.to_json().map(|text| Conversion {
662                text,
663                warnings: net
664                    .non_finite_fields()
665                    .into_iter()
666                    .map(|path| {
667                        format!(
668                            "{path} is not finite; JSON has no Inf/NaN, so it is written as \
669                             null and this snapshot will not read back as powerio-json"
670                        )
671                    })
672                    .collect(),
673            });
674        }
675        TargetFormat::Pslf => write_pslf(net),
676        TargetFormat::SurgeJson => write_surge_json(net),
677        TargetFormat::Goc3Json => {
678            return Err(Error::WriteUnsupported {
679                format: "goc3-json",
680            });
681        }
682    };
683    warn_normalized_tap(net, format, &mut conv);
684    warn_missing_reference(net, format, &mut conv);
685    warn_dropped_frequency(net, format, &mut conv);
686    warn_psse_downgrade(net, format, &mut conv);
687    warn_dropped_transformer_charging(net, format, &mut conv);
688    Ok(conv)
689}
690
691/// Convert a [`Network`] with write-time cost policies. The old [`write_as`]
692/// behavior is preserved when `options` is default.
693pub fn write_as_with_options(
694    net: &Network,
695    format: TargetFormat,
696    options: &WriteOptions,
697) -> Result<Conversion> {
698    if options.is_default() {
699        return write_as(net, format);
700    }
701
702    let mut working = net.clone();
703    let report =
704        working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
705    let mut policy_warnings = Vec::new();
706    if report.patched > 0 {
707        policy_warnings.push(format!(
708            "generator cost patch applied to {} generator(s)",
709            report.patched
710        ));
711    }
712    if report.synthesized > 0 {
713        policy_warnings.push(match options.missing_gen_cost {
714            MissingGenCostPolicy::Fill {
715                c2,
716                c1,
717                c0,
718                startup,
719                shutdown,
720            } => format!(
721                "generator cost synthesized for {} generator(s): model 2, ncost 3, \
722                 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
723                report.synthesized
724            ),
725            _ => unreachable!("only Fill synthesizes costs"),
726        });
727    }
728    if report.patched > 0 || report.synthesized > 0 {
729        working.source = None;
730    }
731
732    let mut conv = write_as(&working, format)?;
733    policy_warnings.append(&mut conv.warnings);
734    conv.warnings = policy_warnings;
735    Ok(conv)
736}
737
738/// Allocate a circuit id for an element keyed by `key` — a bus for loads/shunts,
739/// or a `(from, to)` pair for branches: reuse the source-supplied `preferred` id
740/// when it is still free on this key, else the lowest free positional id. Keeps
741/// parallel devices distinct so the `(key, id)` uniqueness rule the PSS/E and
742/// PSLF records require holds even when the source supplies colliding ids.
743pub(super) fn allocate_circuit_id<K: Ord + Clone>(
744    preferred: Option<&str>,
745    key: K,
746    used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
747) -> String {
748    let taken = used.entry(key).or_default();
749    if let Some(id) = preferred {
750        if taken.insert(id.to_owned()) {
751            return id.to_owned();
752        }
753    }
754    let mut n = 1u32;
755    loop {
756        let candidate = n.to_string();
757        if taken.insert(candidate.clone()) {
758            return candidate;
759        }
760        n += 1;
761    }
762}
763
764/// Warn when a PSS/E source is re-serialized at an older revision than its own.
765/// `parse_file` maps every `.raw` to revision 33 and the `psse`/`raw` aliases
766/// resolve to 33, so writing a v34/v35 source through the default target skips
767/// the echo path (revisions differ) and re-emits the v33 layout, dropping the
768/// modern records (12 named ratings, load DG/LOADTYPE columns, the system-wide
769/// block) and any unmodeled section the echo would have preserved. Name the
770/// downgrade instead of performing it silently.
771fn warn_psse_downgrade(net: &Network, format: TargetFormat, conv: &mut Conversion) {
772    if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) =
773        (format, net.source_format, net.source.as_ref())
774    {
775        let src_rev = psse::header_rev(src);
776        if src_rev > rev {
777            conv.warnings.push(format!(
778                "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
779                 the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
780            ));
781        }
782    }
783}
784
785/// Warn when a non-default system frequency writes to a format with no frequency
786/// field. PSS/E (`BASFRQ`) and pandapower (`f_hz`) carry it; MATPOWER,
787/// PowerModels, egret, and PowerWorld have nowhere to put it, so a 50 Hz case
788/// would silently read back as the 60 Hz default. Report the loss instead.
789fn warn_dropped_frequency(net: &Network, format: TargetFormat, conv: &mut Conversion) {
790    let carries_frequency = matches!(
791        format,
792        TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
793    );
794    if carries_frequency {
795        return;
796    }
797    if (net.base_frequency - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
798        conv.warnings.push(format!(
799            "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
800            net.base_frequency,
801            format.label(),
802            crate::network::DEFAULT_BASE_FREQUENCY
803        ));
804    }
805}
806
807/// Warn when a transformer carries line charging and the target's
808/// transformer record has no susceptance column to hold it. The PSLF `.epc`
809/// transformer record is the one such target; PSS/E writes representable
810/// magnetizing admittance and the MATPOWER shaped writers keep the legacy total
811/// projection on the branch row, so neither drops it.
812fn warn_dropped_transformer_charging(net: &Network, format: TargetFormat, conv: &mut Conversion) {
813    if !matches!(format, TargetFormat::Pslf) {
814        return;
815    }
816    let n = net
817        .branches
818        .iter()
819        .filter(|b| b.is_transformer() && b.legacy_total_charging_b() != 0.0)
820        .count();
821    if n > 0 {
822        conv.warnings.push(format!(
823            "{n} transformer(s) carry line charging that the PSLF .epc transformer \
824             record cannot represent; the charging was dropped"
825        ));
826    }
827}
828
829pub(super) fn branch_rating_set_drop_warning(
830    target: &str,
831    branch_index: usize,
832    branch: &Branch,
833    rating: &BranchRatingSet,
834) -> String {
835    format!(
836        "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
837        branch_index + 1,
838        branch.from,
839        branch.to,
840        rating.name,
841        rating.rate_mva,
842        target
843    )
844}
845
846pub(super) fn warn_extra_branch_rating_sets(
847    target: &str,
848    net: &Network,
849    warnings: &mut Vec<String>,
850) {
851    for (branch_index, branch) in net.branches.iter().enumerate() {
852        for rating in &branch.rating_sets {
853            warnings.push(branch_rating_set_drop_warning(
854                target,
855                branch_index,
856                branch,
857                rating,
858            ));
859        }
860    }
861}
862
863/// Convert a case file to `to`, optionally forcing the source format with
864/// `from`.
865///
866/// This is the canonical file-conversion helper shared by the bindings. It
867/// parses `path` once, writes the resulting [`Network`] to `to`, and returns the
868/// converted text plus any fidelity warnings, read side first. An echo (writing
869/// back to the source format) returns the retained text with no warnings.
870///
871/// # Errors
872/// As [`parse_file`].
873pub fn convert_file(
874    path: impl AsRef<std::path::Path>,
875    to: TargetFormat,
876    from: Option<&str>,
877) -> Result<Conversion> {
878    let parsed = parse_file(path, from)?;
879    let mut conv = write_as(&parsed.network, to)?;
880    if !is_echo(&parsed.network, to) {
881        conv.warnings.splice(0..0, parsed.warnings);
882    }
883    Ok(conv)
884}
885
886/// Convert a case file with write-time cost policies.
887pub fn convert_file_with_options(
888    path: impl AsRef<std::path::Path>,
889    to: TargetFormat,
890    from: Option<&str>,
891    options: &WriteOptions,
892) -> Result<Conversion> {
893    let parsed = parse_file(path, from)?;
894    let mut conv = write_as_with_options(&parsed.network, to, options)?;
895    if !is_echo(&parsed.network, to) || !options.is_default() {
896        conv.warnings.splice(0..0, parsed.warnings);
897    }
898    Ok(conv)
899}
900
901/// Convert in-memory case `text` of the named `format` (see
902/// [`target_format_from_name`]) to `to`.
903///
904/// The in-memory sibling of [`convert_file`], shared by the bindings: parses
905/// `text` once and writes the resulting [`Network`] to `to`, with no file
906/// staging in between. Warnings are read side first, as in [`convert_file`].
907///
908/// # Errors
909/// As [`parse_str`].
910pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
911    let parsed = parse_str(text, format)?;
912    let mut conv = write_as(&parsed.network, to)?;
913    if !is_echo(&parsed.network, to) {
914        conv.warnings.splice(0..0, parsed.warnings);
915    }
916    Ok(conv)
917}
918
919/// Convert in-memory case text with write-time cost policies.
920pub fn convert_str_with_options(
921    text: &str,
922    to: TargetFormat,
923    format: &str,
924    options: &WriteOptions,
925) -> Result<Conversion> {
926    let parsed = parse_str(text, format)?;
927    let mut conv = write_as_with_options(&parsed.network, to, options)?;
928    if !is_echo(&parsed.network, to) || !options.is_default() {
929        conv.warnings.splice(0..0, parsed.warnings);
930    }
931    Ok(conv)
932}
933
934/// Write `net` into the directory `out_dir` as the named directory-shaped
935/// format: the directory sibling of [`write_as`], sharing its name-dispatch
936/// role for the bindings. PyPSA CSV (`pypsa-csv`/`pypsa`) is the one such
937/// format today; a text format name is rejected by name, pointing at
938/// [`write_as`]. Returns the write's fidelity warnings.
939///
940/// # Errors
941/// [`Error::UnknownFormat`] for a non-directory format name; the writer's own
942/// [`Error`] otherwise.
943pub fn write_dir(
944    net: &Network,
945    to: &str,
946    out_dir: impl AsRef<std::path::Path>,
947) -> Result<Vec<String>> {
948    if is_pypsa_csv_name(to) {
949        return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings);
950    }
951    Err(Error::UnknownFormat(format!(
952        "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
953         text formats serialize through write_as / to_format"
954    )))
955}
956
957/// Warn when a network with no reference (slack) bus converts to a format
958/// whose solvers require one. PowerWorld `.pwb` is the one source that
959/// systematically lacks the designation (the binary does not store it), so
960/// the silent case would be common; `to_normalized` synthesizes a slack at
961/// the largest pmax in service generator bus for consumers that need one.
962fn warn_missing_reference(net: &Network, format: TargetFormat, conv: &mut Conversion) {
963    let needs_ref = matches!(
964        format,
965        TargetFormat::Matpower
966            | TargetFormat::Psse { .. }
967            | TargetFormat::PowerModelsJson
968            | TargetFormat::PandapowerJson
969            | TargetFormat::Pslf
970            | TargetFormat::SurgeJson
971    );
972    if needs_ref {
973        conv.warnings.extend(missing_reference_warning(net));
974    }
975}
976
977/// The slackless-network warning itself, shared with the PyPSA folder writer
978/// (which produces `PypsaCsvOutputs`, not a [`Conversion`], so it cannot go
979/// through [`warn_missing_reference`]).
980pub(super) fn missing_reference_warning(net: &Network) -> Option<String> {
981    (!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
982        "no reference (slack) bus in the source network; power flow tools \
983         reject such cases; to_normalized synthesizes a slack at the \
984         largest pmax in service generator bus"
985            .to_string()
986    })
987}
988
989/// A normalized network has its tap canonicalized to `1.0` on every line (the
990/// `0 → 1` rule), but [`Branch::is_transformer`](crate::network::Branch::is_transformer),
991/// the test these writers use to split lines from transformers, keys off
992/// `tap != 0`. So a normalized line is written into the transformer section/type.
993/// The power flow is identical (a unity-ratio, zero-shift transformer equals a
994/// line), but the label is not, so report the fidelity loss rather than relabel
995/// it silently. MATPOWER has no separate transformer representation (just a `TAP`
996/// column), so it is exempt.
997// `tap == 1.0` / `shift == 0.0` are exact by construction: normalization sets a
998// line's tap from `effective_tap()` (the literal `1.0`) and its shift from
999// `0.0 * DEG_TO_RAD` (exactly `0.0`), so an epsilon compare would be wrong here.
1000#[allow(clippy::float_cmp)]
1001fn warn_normalized_tap(net: &Network, format: TargetFormat, conv: &mut Conversion) {
1002    if matches!(format, TargetFormat::Matpower) {
1003        return;
1004    }
1005    conv.warnings.extend(normalized_tap_warning(net));
1006}
1007
1008/// The normalized-label warning itself, shared with the PyPSA folder writer.
1009// `tap == 1.0` / `shift == 0.0` are exact by construction (see
1010// `warn_normalized_tap`), so an epsilon compare would be wrong here.
1011#[allow(clippy::float_cmp)]
1012pub(super) fn normalized_tap_warning(net: &Network) -> Option<String> {
1013    if !net.is_normalized() {
1014        return None;
1015    }
1016    // After normalization a line (raw tap 0) and a unity-ratio transformer (raw
1017    // tap 1) both read as tap 1.0 / shift 0.0, so they cannot be told apart. Count
1018    // them together as the branches whose line/transformer label is now ambiguous.
1019    let ambiguous = net
1020        .branches
1021        .iter()
1022        .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1023        .count();
1024    (ambiguous > 0).then(|| {
1025        format!(
1026            "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1027             shift, so the line/transformer label is not preserved (the power flow \
1028             is identical)"
1029        )
1030    })
1031}
1032
1033/// True when `value` is set and deviates from `reference`: the shared test for
1034/// "does this rating column carry information the target cannot" used by the
1035/// rate_b/rate_c drop warnings.
1036fn nonzero_differs(value: f64, reference: f64) -> bool {
1037    value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1038}
1039
1040/// Set a bus's kind through the `bus_pos` index, leaving Isolated buses alone.
1041/// Shared by the readers that derive bus kinds from generator/slack tables.
1042pub(crate) fn set_bus_kind(
1043    buses: &mut [Bus],
1044    bus_pos: &HashMap<BusId, usize>,
1045    bus: BusId,
1046    kind: BusType,
1047) {
1048    if let Some(&idx) = bus_pos.get(&bus) {
1049        if buses[idx].kind != BusType::Isolated {
1050            buses[idx].kind = kind;
1051        }
1052    }
1053}
1054
1055/// `base_kv` of a bus through the `bus_pos` index; 0.0 for an unknown bus.
1056pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1057    bus_pos
1058        .get(&bus)
1059        .and_then(|&i| buses.get(i))
1060        .map_or(0.0, |b| b.base_kv)
1061}
1062
1063/// Replace characters that would corrupt a quoted or delimited field with
1064/// `replacement`, so a free-form name can't shift or truncate the record it sits
1065/// in. `forbidden` lists the destination's quote, delimiter, and comment chars.
1066/// Returns the value borrowed unchanged when it holds none of them, so the common
1067/// clean-name path allocates nothing.
1068///
1069/// Each text writer calls this at its quoting seam and warns when the result
1070/// differs from the input (the substitution silently alters operator-facing
1071/// names): the PSS/E single-quoted bus name and the PowerWorld double-quoted bus
1072/// name both interpolate a `Network` name straight into a quoted field, where an
1073/// embedded quote (or, for PSS/E, the `/` inline-comment delimiter) would shift
1074/// every later column of the record.
1075pub(crate) fn sanitize_quoted<'a>(
1076    value: &'a str,
1077    forbidden: &[char],
1078    replacement: char,
1079) -> std::borrow::Cow<'a, str> {
1080    if value.contains(forbidden) {
1081        value
1082            .chars()
1083            .map(|c| {
1084                if forbidden.contains(&c) {
1085                    replacement
1086                } else {
1087                    c
1088                }
1089            })
1090            .collect::<String>()
1091            .into()
1092    } else {
1093        std::borrow::Cow::Borrowed(value)
1094    }
1095}
1096
1097/// Impedance base `v_kv² / base_mva`; 1.0 when either base is missing, so a
1098/// per-unit ↔ ohm conversion on it is the identity.
1099pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1100    if v_kv > 0.0 && base_mva > 0.0 {
1101        v_kv * v_kv / base_mva
1102    } else {
1103        1.0
1104    }
1105}
1106
1107/// Whether writing `net` to `target` echoes the retained source text: the
1108/// target is the source format and the source is still attached. An echo
1109/// reproduces the input byte for byte, so read fidelity warnings don't apply.
1110fn is_echo(net: &Network, target: TargetFormat) -> bool {
1111    let Some(src) = &net.source else { return false };
1112    if !same_format(target, net.source_format) {
1113        return false;
1114    }
1115    // A PSS/E source echoes only when the requested revision equals the source's
1116    // own; any other revision must go through write_psse_rev so the caller gets
1117    // the layout it asked for instead of the original bytes.
1118    if let TargetFormat::Psse { rev } = target {
1119        return psse::header_rev(src) == rev;
1120    }
1121    true
1122}
1123
1124/// Whether a write target is the same format the network was read from.
1125fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1126    matches!(
1127        (target, source),
1128        (TargetFormat::Matpower, SourceFormat::Matpower)
1129            | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1130            | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1131            | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1132            | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1133            | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1134            | (TargetFormat::Pslf, SourceFormat::Pslf)
1135            | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1136            | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1137    )
1138}
1139
1140/// JSON number for a finite `f64`; `Value::Null` for `NaN`/`±Inf`.
1141pub(crate) fn jnum(x: f64) -> Value {
1142    serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1143}
1144
1145/// Serialize a built JSON tree into a [`Conversion`], appending one warning that
1146/// names every field where a non-finite `f64` was written as `null` (JSON has no
1147/// `±Inf`/`NaN`). Shared by the JSON writers.
1148pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
1149    let value = Value::Object(root);
1150    let mut nulls = BTreeSet::new();
1151    collect_null_keys(&value, &mut nulls);
1152    if !nulls.is_empty() {
1153        warnings.push(format!(
1154            "non-finite numeric values written as JSON null in field(s): {}",
1155            nulls.into_iter().collect::<Vec<_>>().join(", ")
1156        ));
1157    }
1158    let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1159    Conversion { text, warnings }
1160}
1161
1162/// Collect the names of object keys whose value is `null`, anywhere in the tree.
1163fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1164    match value {
1165        Value::Object(map) => {
1166            for (key, val) in map {
1167                if val.is_null() {
1168                    out.insert(key.clone());
1169                } else {
1170                    collect_null_keys(val, out);
1171                }
1172            }
1173        }
1174        Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1175        _ => {}
1176    }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182    use crate::network::SourceFormat;
1183
1184    #[test]
1185    fn source_format_strings_round_trip_to_a_target() {
1186        // The bindings expose `source_format` as its `{:?}` form, and
1187        // `to_format` routes that string back through `target_format_from_name`.
1188        // Every writable source format must resolve, including PowerModelsJson /
1189        // EgretJson, whose camel-case names need the `powermodelsjson` /
1190        // `egretjson` aliases (issue #75).
1191        for (sf, want) in [
1192            (SourceFormat::Matpower, TargetFormat::Matpower),
1193            (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
1194            (SourceFormat::EgretJson, TargetFormat::EgretJson),
1195            (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
1196            (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
1197            (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
1198            (SourceFormat::Pslf, TargetFormat::Pslf),
1199            (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
1200            (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
1201        ] {
1202            let token = format!("{sf:?}");
1203            assert_eq!(
1204                target_format_from_name(&token),
1205                Some(want),
1206                "source_format {token:?} did not round-trip"
1207            );
1208        }
1209        // The derived/in-memory source formats have no writer target, and
1210        // neither does the read only .pwb binary.
1211        for sf in [
1212            SourceFormat::InMemory,
1213            SourceFormat::Normalized,
1214            SourceFormat::Gridfm,
1215            SourceFormat::PypsaCsv,
1216            SourceFormat::PowerWorldBinary,
1217        ] {
1218            assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
1219        }
1220    }
1221}