Skip to main content

powerio/format/
mod.rs

1//! The format hub: readers and writers for every supported file format, all
2//! meeting at the shared [`Network`].
3//!
4//! Each format is one module here, owning its reader and/or writer: MATPOWER
5//! `.m`, PowerModels JSON, PSS/E `.raw`, PowerWorld `.aux`, egret
6//! `ModelData` JSON, pandapower JSON, and PyPSA CSV folders. PowerWorld `.pwb`
7//! cases and `.pwd` displays are read only. Case input and output formats meet
8//! at the hub, so adding a format is one module, not a change to any other.
9//! [`parse_file`] reads Network cases, detecting the format from its extension;
10//! [`parse_display_file`] reads display artifacts such as PowerWorld `.pwd`.
11//! [`write_as`]
12//! serializes a `Network` to text targets.
13//! Writers for directory formats, such as PyPSA CSV folders, expose explicit
14//! filesystem helpers. Non-finite numeric values (a MATPOWER `Inf`/`NaN` angle
15//! limit, say) are written as JSON `null`.
16//!
17//! # Fidelity contract
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::network::{Bus, BusId, BusType, Network, SourceFormat};
37use crate::{Error, Result};
38
39mod egret;
40mod matpower;
41mod pandapower;
42mod powermodels;
43pub mod powerworld;
44mod psse;
45mod pypsa;
46
47pub use egret::{parse_egret_json, write_egret_json};
48pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
49pub use pandapower::{parse_pandapower_json, write_pandapower_json};
50pub use powermodels::{parse_powermodels_json, write_powermodels_json};
51pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
52pub use psse::{parse_psse, write_psse};
53pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
54
55/// A target interchange format. See [`write_as`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum TargetFormat {
59    /// PowerModels.jl network data JSON.
60    PowerModelsJson,
61    /// egret `ModelData` JSON.
62    EgretJson,
63    /// PSS/E `.raw` (v33).
64    Psse,
65    /// PowerWorld auxiliary `.aux`.
66    PowerWorld,
67    /// pandapower `pandapowerNet` JSON.
68    PandapowerJson,
69    /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source).
70    Matpower,
71}
72
73impl TargetFormat {
74    /// Conventional file extension for this format (no leading dot).
75    #[must_use]
76    pub fn extension(self) -> &'static str {
77        match self {
78            TargetFormat::PowerModelsJson
79            | TargetFormat::EgretJson
80            | TargetFormat::PandapowerJson => "json",
81            TargetFormat::Psse => "raw",
82            TargetFormat::PowerWorld => "aux",
83            TargetFormat::Matpower => "m",
84        }
85    }
86
87    /// Human-readable format name for diagnostics.
88    #[must_use]
89    pub fn label(self) -> &'static str {
90        match self {
91            TargetFormat::PowerModelsJson => "PowerModels JSON",
92            TargetFormat::EgretJson => "egret JSON",
93            TargetFormat::Psse => "PSS/E .raw",
94            TargetFormat::PowerWorld => "PowerWorld .aux",
95            TargetFormat::PandapowerJson => "pandapower JSON",
96            TargetFormat::Matpower => "MATPOWER .m",
97        }
98    }
99
100    /// Canonical API token for this format.
101    #[must_use]
102    pub fn token(self) -> &'static str {
103        match self {
104            TargetFormat::PowerModelsJson => "powermodels-json",
105            TargetFormat::EgretJson => "egret-json",
106            TargetFormat::Psse => "psse",
107            TargetFormat::PowerWorld => "powerworld",
108            TargetFormat::PandapowerJson => "pandapower-json",
109            TargetFormat::Matpower => "matpower",
110        }
111    }
112}
113
114impl fmt::Display for TargetFormat {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(self.token())
117    }
118}
119
120impl FromStr for TargetFormat {
121    type Err = Error;
122
123    fn from_str(name: &str) -> Result<Self> {
124        target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
125    }
126}
127
128/// A display artifact format. These files are not power network cases and do
129/// not parse to [`Network`].
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum DisplayFormat {
133    /// PowerWorld oneline display `.pwd`.
134    PowerWorld,
135}
136
137impl DisplayFormat {
138    /// Conventional file extension for this display format (no leading dot).
139    #[must_use]
140    pub fn extension(self) -> &'static str {
141        match self {
142            DisplayFormat::PowerWorld => "pwd",
143        }
144    }
145
146    /// Human-readable format name for diagnostics.
147    #[must_use]
148    pub fn label(self) -> &'static str {
149        match self {
150            DisplayFormat::PowerWorld => "PowerWorld .pwd",
151        }
152    }
153
154    /// Canonical API token for this format.
155    #[must_use]
156    pub fn token(self) -> &'static str {
157        match self {
158            DisplayFormat::PowerWorld => "powerworld-display",
159        }
160    }
161}
162
163impl fmt::Display for DisplayFormat {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.write_str(self.token())
166    }
167}
168
169impl FromStr for DisplayFormat {
170    type Err = Error;
171
172    fn from_str(name: &str) -> Result<Self> {
173        display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
174    }
175}
176
177/// Map a display format name to a [`DisplayFormat`], or `None` if unrecognized.
178/// Accepts `pwd`, `powerworld-pwd`, and `powerworld-display`.
179#[must_use]
180pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
181    Some(match name.to_ascii_lowercase().as_str() {
182        "pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
183        _ => return None,
184    })
185}
186
187/// Map a format name (with the common aliases) to a [`TargetFormat`], or `None`
188/// if unrecognized. Accepts `matpower`/`m`, `powermodels-json`/`powermodels`/`pm`,
189/// `egret-json`/`egret`, `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`,
190/// `powerworld`/`aux`. Case-insensitive. The one place the bindings (Python, C
191/// ABI) share, so a new text format means one new arm here, not three. PyPSA
192/// CSV folders are directory inputs with no text target; their aliases are
193/// matched by the private `is_pypsa_csv_name` next to this.
194///
195/// The `powermodelsjson`/`egretjson`/`pandapowerjson` aliases let a
196/// [`SourceFormat`]'s string form (`{:?}` lowercased, e.g. `"PowerModelsJson"`)
197/// round-trip back to a target, so `net.to_format(other.source_format)` works
198/// for every format.
199#[must_use]
200pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
201    Some(match name.to_ascii_lowercase().as_str() {
202        "matpower" | "m" => TargetFormat::Matpower,
203        "powermodels-json" | "powermodels" | "powermodelsjson" | "pm" => {
204            TargetFormat::PowerModelsJson
205        }
206        "egret-json" | "egret" | "egretjson" => TargetFormat::EgretJson,
207        "psse" | "raw" => TargetFormat::Psse,
208        "powerworld" | "aux" => TargetFormat::PowerWorld,
209        "pandapower-json" | "pandapower" | "pandapowerjson" | "pp" => TargetFormat::PandapowerJson,
210        _ => return None,
211    })
212}
213
214/// Output of a display parse. v0.2.2 supports PowerWorld `.pwd`; future display
215/// formats can add variants without changing the parse entry point.
216#[derive(Debug, Clone, PartialEq)]
217#[non_exhaustive]
218pub enum DisplayData {
219    /// PowerWorld oneline display data.
220    PowerWorld(PwdDisplay),
221}
222
223impl DisplayData {
224    /// The display format represented by this value.
225    #[must_use]
226    pub fn format(&self) -> DisplayFormat {
227        match self {
228            DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
229        }
230    }
231}
232
233fn display_file_guidance() -> Error {
234    Error::UnknownFormat(
235        "a PowerWorld .pwd is display data, not a Network case; \
236         use parse_display_file(path, None)"
237            .into(),
238    )
239}
240
241/// Parse display bytes in the named display `format`.
242///
243/// # Errors
244/// [`Error::UnknownFormat`] if `format` is not a display format; otherwise the
245/// reader's own [`Error`] on malformed input.
246pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
247    let fmt =
248        display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
249    match fmt {
250        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
251            bytes,
252        )?)),
253    }
254}
255
256/// Parse the display file at `path`, choosing the reader from `from` or, when
257/// `None`, from the extension. v0.2.2 infers PowerWorld `.pwd`.
258///
259/// # Errors
260/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension cannot
261/// be mapped; [`Error::Io`] if the file cannot be read; the reader's own
262/// [`Error`] on malformed input.
263pub fn parse_display_file(
264    path: impl AsRef<std::path::Path>,
265    from: Option<&str>,
266) -> Result<DisplayData> {
267    let path = path.as_ref();
268    let fmt = match from {
269        Some(f) => {
270            display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
271        }
272        None => match path
273            .extension()
274            .and_then(|e| e.to_str())
275            .map(str::to_ascii_lowercase)
276            .as_deref()
277        {
278            Some("pwd") => DisplayFormat::PowerWorld,
279            other => {
280                return Err(Error::UnknownFormat(format!(
281                    "cannot infer display format from file extension {other:?}; \
282                     pass an explicit display format"
283                )));
284            }
285        },
286    };
287    let bytes = std::fs::read(path)?;
288    match fmt {
289        DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
290            &bytes,
291        )?)),
292    }
293}
294
295/// Whether a format name means a PyPSA CSV folder. PyPSA folders are directory
296/// inputs, not text targets, so they have no [`TargetFormat`] arm; this is the
297/// companion alias matcher to [`target_format_from_name`] and the one place the
298/// PyPSA aliases live.
299fn is_pypsa_csv_name(name: &str) -> bool {
300    matches!(
301        name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
302        "pypsacsv" | "pypsa"
303    )
304}
305
306/// Parse the case file at `path`, choosing the reader from `from` (the
307/// [`target_format_from_name`] names plus `pypsa-csv`/`pypsa` and `pwb`) or,
308/// when `None`, from the path: a directory containing `network.csv` parses as
309/// a PyPSA CSV folder (any other directory fails: [`Error::UnknownFormat`]
310/// when its name maps to no extension, the I/O error otherwise), and a
311/// file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`), case-insensitively
312/// (issue #97: `.RAW` is as common as `.raw` in the wild). A `.json` file is
313/// sniffed three ways: pandapower (`"_class": "pandapowerNet"`), egret (top
314/// level `elements` and `system`), else PowerModels. Pass `from` to force one.
315/// `.pwb` binaries are read only and carry no retained source. Returns
316/// [`Parsed`]: the network plus the reader's fidelity warnings.
317///
318/// The one path-based parser the CLI and the Python/C/Julia bindings share (each
319/// exposes the same `parse_file(path, from)` shape), so adding a source format is
320/// one edit here, not one per binding. For in-memory text use [`parse_str`].
321///
322/// # Errors
323/// [`Error::UnknownFormat`] if `from` is unrecognized or the extension can't be
324/// mapped; [`Error::Io`] if the file can't be read; the reader's own [`Error`]
325/// on malformed input.
326pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
327    let path = path.as_ref();
328    // PyPSA CSV folders are directories, not files; dispatch them before any
329    // extension logic. `from` accepts the pypsa aliases, and a bare directory
330    // with a `network.csv` auto-detects.
331    if from.is_some_and(is_pypsa_csv_name)
332        || (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
333    {
334        return pypsa::read_pypsa_csv_folder(path);
335    }
336    // PowerWorld `.pwb` is binary and read only; dispatch it before the text
337    // read. `from` accepts "pwb" for files with a different extension.
338    let ext = path
339        .extension()
340        .and_then(|e| e.to_str())
341        .map(str::to_ascii_lowercase);
342    if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
343        || (from.is_none() && ext.as_deref() == Some("pwb"))
344    {
345        let bytes = std::fs::read(path)?;
346        let stem = path.file_stem().and_then(|s| s.to_str());
347        // The binary reader is total (no fidelity warnings); wrap its network
348        // in the shared [`Parsed`] shape.
349        let network = powerworld::parse_pwb(&bytes, stem)?;
350        return Ok(Parsed {
351            network,
352            warnings: Vec::new(),
353        });
354    }
355    // Settle the format before touching the file: an unmapped or binary
356    // extension must surface as UnknownFormat, not as the UTF-8 read error
357    // the text formats' loader would hit first. `.pwd` gets its own arm
358    // because the display sibling ships next to every case file in the wild
359    // and carries no case data.
360    if from.is_none() && ext.as_deref() == Some("pwd") {
361        return Err(display_file_guidance());
362    }
363    let fmt_hint = match from {
364        Some(f) => {
365            if display_format_from_name(f).is_some() {
366                return Err(display_file_guidance());
367            }
368            Some(target_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?)
369        }
370        None => {
371            // Everything but `.json` (sniffed below) resolves without the text.
372            match ext.as_deref() {
373                Some("m") => Some(TargetFormat::Matpower),
374                Some("raw") => Some(TargetFormat::Psse),
375                Some("aux") => Some(TargetFormat::PowerWorld),
376                Some("json") => None,
377                other => {
378                    return Err(Error::UnknownFormat(format!(
379                        "cannot infer from file extension {other:?}; \
380                         pass an explicit source format"
381                    )));
382                }
383            }
384        }
385    };
386    // Read the file once into an owned buffer; the reader moves it straight into
387    // the retained source (byte-exact round-trip) with no copy. Sniffing a
388    // `.json` borrows the text before the move.
389    let text = std::fs::read_to_string(path)?;
390    let fmt = fmt_hint.unwrap_or_else(|| sniff_json(&text));
391    // The file stem is the name hint for formats that don't carry their own name.
392    let stem = path.file_stem().and_then(|s| s.to_str());
393    read_source(Arc::new(text), fmt, stem)
394}
395
396/// Read an owned `source` buffer as `fmt`, using `name_hint` (e.g. the file
397/// stem) when the format carries no name of its own. The single format→reader
398/// map: [`parse_file`] and [`parse_str`] both funnel through it, so every format
399/// is dispatched the same way. Each reader takes the owned `Arc` so
400/// it moves the buffer straight into the retained source (no copy) and is free
401/// to specialize its parse internally. Owns the [`Parsed`] warnings vector;
402/// readers that report fidelity loss append to it.
403fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
404    let mut warnings = Vec::new();
405    let net = match fmt {
406        TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
407        TargetFormat::PowerModelsJson => {
408            powermodels::parse_powermodels_json_source(source, name_hint)
409        }
410        TargetFormat::Psse => psse::parse_psse_source(source, name_hint),
411        TargetFormat::PowerWorld => powerworld::parse_powerworld_source(source, name_hint),
412        TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
413        TargetFormat::PandapowerJson => {
414            pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
415        }
416    }?;
417    reject_empty_case(&net, fmt.label())?;
418    Ok(Parsed {
419        network: net,
420        warnings,
421    })
422}
423
424/// A case with no buses is content-free for every consumer. Most readers
425/// already reject it on a missing required table, but a JSON carrying only
426/// `baseMVA` would otherwise parse to a hollow network; reject it in the
427/// [`read_source`] funnel so every parse path (file and in-memory) is guarded,
428/// and in the PyPSA folder reader, which bypasses the funnel.
429pub(crate) fn reject_empty_case(net: &Network, format: &'static str) -> Result<()> {
430    if net.buses.is_empty() {
431        return Err(Error::FormatRead {
432            format,
433            message: "case has no buses".into(),
434        });
435    }
436    Ok(())
437}
438
439/// The interchange JSON formats share the `.json` extension, so an explicit
440/// source format isn't always given. Sniff three ways: pandapower declares
441/// itself (`"_class": "pandapowerNet"`); egret `ModelData` has top level
442/// `elements` and `system`; else fall back to PowerModels (the more common
443/// input).
444///
445/// Deserializing into [`IgnoredAny`] fields scans the JSON to find the
446/// top level keys without building the whole `Value` tree, so a large
447/// PowerModels file isn't fully allocated here only to be parsed again by its
448/// reader.
449fn sniff_json(text: &str) -> TargetFormat {
450    use serde::de::IgnoredAny;
451    #[derive(serde::Deserialize)]
452    struct Shape {
453        #[serde(rename = "_class")]
454        class: Option<String>,
455        elements: Option<IgnoredAny>,
456        system: Option<IgnoredAny>,
457    }
458    match serde_json::from_str::<Shape>(text) {
459        Ok(Shape {
460            class: Some(class), ..
461        }) if class == "pandapowerNet" => TargetFormat::PandapowerJson,
462        Ok(Shape {
463            elements: Some(_),
464            system: Some(_),
465            ..
466        }) => TargetFormat::EgretJson,
467        _ => TargetFormat::PowerModelsJson,
468    }
469}
470
471/// Parse in-memory case `text` of the named `format` (see
472/// [`target_format_from_name`]). Returns [`Parsed`]: the network plus the
473/// reader's fidelity warnings.
474///
475/// # Errors
476/// [`Error::UnknownFormat`] if `format` is unrecognized; the reader's own
477/// [`Error`] on malformed input.
478pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
479    let fmt =
480        target_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
481    read_source(Arc::new(text.to_owned()), fmt, None)
482}
483
484/// Output of a parse: the network plus the reader's fidelity warnings —
485/// tables and columns the model cannot carry, reported instead of dropped
486/// silently. Empty for readers that don't report read warnings (currently
487/// every format except pandapower JSON and PyPSA CSV; the PSS/E and
488/// PowerWorld reductions are documented in docs/format-fidelity.md, not
489/// reported here yet).
490///
491/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
492/// never constructs it, leaving room to add parse metadata without a breaking
493/// change.
494#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub struct Parsed {
497    pub network: Network,
498    pub warnings: Vec<String>,
499}
500
501/// Output of a conversion: the serialized text plus any fidelity warnings:
502/// data the target can't represent, defaults synthesized, or blocks mapped best
503/// effort. An empty `warnings` means a faithful conversion. For [`convert_file`]
504/// and [`convert_str`], `warnings` carries the read side ([`Parsed`] warnings)
505/// too, ahead of the write side.
506///
507/// `#[non_exhaustive]`: a returns-only type, so downstream code reads it but
508/// never constructs it, leaving room to add fidelity metadata without a breaking
509/// change.
510#[derive(Debug, Clone)]
511#[non_exhaustive]
512pub struct Conversion {
513    pub text: String,
514    pub warnings: Vec<String>,
515}
516
517/// Convert a [`Network`] to `format`. Writing back to the source format returns
518/// the retained source text; otherwise the network is serialized into the target.
519#[must_use]
520pub fn write_as(net: &Network, format: TargetFormat) -> Conversion {
521    if is_echo(net, format) {
522        if let Some(src) = &net.source {
523            return Conversion {
524                text: src.to_string(),
525                warnings: Vec::new(),
526            };
527        }
528    }
529    let mut conv = match format {
530        TargetFormat::PowerModelsJson => write_powermodels_json(net),
531        TargetFormat::EgretJson => write_egret_json(net),
532        TargetFormat::Psse => write_psse(net),
533        TargetFormat::PowerWorld => write_powerworld(net),
534        TargetFormat::PandapowerJson => write_pandapower_json(net),
535        // From another source (or no retained source): canonical MATPOWER from
536        // the folded model, which itemizes what it can't carry (HVDC, gen caps,
537        // extras, a partial-cost case).
538        TargetFormat::Matpower => matpower::write_matpower_conversion(net),
539    };
540    warn_normalized_tap(net, format, &mut conv);
541    warn_missing_reference(net, format, &mut conv);
542    conv
543}
544
545/// Convert a case file to `to`, optionally forcing the source format with
546/// `from`.
547///
548/// This is the canonical file-conversion helper shared by the bindings. It
549/// parses `path` once, writes the resulting [`Network`] to `to`, and returns the
550/// converted text plus any fidelity warnings, read side first. An echo (writing
551/// back to the source format) returns the retained text with no warnings.
552///
553/// # Errors
554/// As [`parse_file`].
555pub fn convert_file(
556    path: impl AsRef<std::path::Path>,
557    to: TargetFormat,
558    from: Option<&str>,
559) -> Result<Conversion> {
560    let parsed = parse_file(path, from)?;
561    let mut conv = write_as(&parsed.network, to);
562    if !is_echo(&parsed.network, to) {
563        conv.warnings.splice(0..0, parsed.warnings);
564    }
565    Ok(conv)
566}
567
568/// Convert in-memory case `text` of the named `format` (see
569/// [`target_format_from_name`]) to `to`.
570///
571/// The in-memory sibling of [`convert_file`], shared by the bindings: parses
572/// `text` once and writes the resulting [`Network`] to `to`, with no file
573/// staging in between. Warnings are read side first, as in [`convert_file`].
574///
575/// # Errors
576/// As [`parse_str`].
577pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
578    let parsed = parse_str(text, format)?;
579    let mut conv = write_as(&parsed.network, to);
580    if !is_echo(&parsed.network, to) {
581        conv.warnings.splice(0..0, parsed.warnings);
582    }
583    Ok(conv)
584}
585
586/// Warn when a network with no reference (slack) bus converts to a format
587/// whose solvers require one. PowerWorld `.pwb` is the one source that
588/// systematically lacks the designation (the binary does not store it), so
589/// the silent case would be common; `to_normalized` synthesizes a slack at
590/// the largest pmax in service generator bus for consumers that need one.
591fn warn_missing_reference(net: &Network, format: TargetFormat, conv: &mut Conversion) {
592    let needs_ref = matches!(
593        format,
594        TargetFormat::Matpower
595            | TargetFormat::Psse
596            | TargetFormat::PowerModelsJson
597            | TargetFormat::PandapowerJson
598    );
599    if needs_ref {
600        conv.warnings.extend(missing_reference_warning(net));
601    }
602}
603
604/// The slackless-network warning itself, shared with the PyPSA folder writer
605/// (which produces `PypsaCsvOutputs`, not a [`Conversion`], so it cannot go
606/// through [`warn_missing_reference`]).
607pub(super) fn missing_reference_warning(net: &Network) -> Option<String> {
608    (!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
609        "no reference (slack) bus in the source network; power flow tools \
610         reject such cases — to_normalized synthesizes a slack at the \
611         largest pmax in service generator bus"
612            .to_string()
613    })
614}
615
616/// A normalized network has its tap canonicalized to `1.0` on every line (the
617/// `0 → 1` rule), but [`Branch::is_transformer`](crate::network::Branch::is_transformer),
618/// the test these writers use to split lines from transformers, keys off
619/// `tap != 0`. So a normalized line is written into the transformer section/type.
620/// The power flow is identical (a unity-ratio, zero-shift transformer equals a
621/// line), but the label is not, so report the fidelity loss rather than relabel
622/// it silently. MATPOWER has no separate transformer representation (just a `TAP`
623/// column), so it is exempt.
624// `tap == 1.0` / `shift == 0.0` are exact by construction: normalization sets a
625// line's tap from `effective_tap()` (the literal `1.0`) and its shift from
626// `0.0 * DEG_TO_RAD` (exactly `0.0`), so an epsilon compare would be wrong here.
627#[allow(clippy::float_cmp)]
628fn warn_normalized_tap(net: &Network, format: TargetFormat, conv: &mut Conversion) {
629    if matches!(format, TargetFormat::Matpower) {
630        return;
631    }
632    conv.warnings.extend(normalized_tap_warning(net));
633}
634
635/// The normalized-label warning itself, shared with the PyPSA folder writer.
636// `tap == 1.0` / `shift == 0.0` are exact by construction (see
637// `warn_normalized_tap`), so an epsilon compare would be wrong here.
638#[allow(clippy::float_cmp)]
639pub(super) fn normalized_tap_warning(net: &Network) -> Option<String> {
640    if !net.is_normalized() {
641        return None;
642    }
643    // After normalization a line (raw tap 0) and a unity-ratio transformer (raw
644    // tap 1) both read as tap 1.0 / shift 0.0, so they cannot be told apart. Count
645    // them together as the branches whose line/transformer label is now ambiguous.
646    let ambiguous = net
647        .branches
648        .iter()
649        .filter(|b| b.tap == 1.0 && b.shift == 0.0)
650        .count();
651    (ambiguous > 0).then(|| {
652        format!(
653            "normalized network: {ambiguous} branch(es) have unit tap and no phase \
654             shift, so the line/transformer label is not preserved (the power flow \
655             is identical)"
656        )
657    })
658}
659
660/// True when `value` is set and deviates from `reference`: the shared test for
661/// "does this rating column carry information the target cannot" used by the
662/// rate_b/rate_c drop warnings.
663fn nonzero_differs(value: f64, reference: f64) -> bool {
664    value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
665}
666
667/// Set a bus's kind through the `bus_pos` index, leaving Isolated buses alone.
668/// Shared by the readers that derive bus kinds from generator/slack tables.
669pub(crate) fn set_bus_kind(
670    buses: &mut [Bus],
671    bus_pos: &HashMap<BusId, usize>,
672    bus: BusId,
673    kind: BusType,
674) {
675    if let Some(&idx) = bus_pos.get(&bus) {
676        if buses[idx].kind != BusType::Isolated {
677            buses[idx].kind = kind;
678        }
679    }
680}
681
682/// `base_kv` of a bus through the `bus_pos` index; 0.0 for an unknown bus.
683pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
684    bus_pos
685        .get(&bus)
686        .and_then(|&i| buses.get(i))
687        .map_or(0.0, |b| b.base_kv)
688}
689
690/// Impedance base `v_kv² / base_mva`; 1.0 when either base is missing, so a
691/// per-unit ↔ ohm conversion on it is the identity.
692pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
693    if v_kv > 0.0 && base_mva > 0.0 {
694        v_kv * v_kv / base_mva
695    } else {
696        1.0
697    }
698}
699
700/// Whether writing `net` to `target` echoes the retained source text: the
701/// target is the source format and the source is still attached. An echo
702/// reproduces the input byte for byte, so read fidelity warnings don't apply.
703fn is_echo(net: &Network, target: TargetFormat) -> bool {
704    same_format(target, net.source_format) && net.source.is_some()
705}
706
707/// Whether a write target is the same format the network was read from.
708fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
709    matches!(
710        (target, source),
711        (TargetFormat::Matpower, SourceFormat::Matpower)
712            | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
713            | (TargetFormat::EgretJson, SourceFormat::EgretJson)
714            | (TargetFormat::Psse, SourceFormat::Psse)
715            | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
716            | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
717    )
718}
719
720/// JSON number for a finite `f64`; `Value::Null` for `NaN`/`±Inf`.
721pub(crate) fn jnum(x: f64) -> Value {
722    serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
723}
724
725/// Serialize a built JSON tree into a [`Conversion`], appending one warning that
726/// names every field where a non-finite `f64` was written as `null` (JSON has no
727/// `±Inf`/`NaN`). Shared by the JSON writers.
728pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
729    let value = Value::Object(root);
730    let mut nulls = BTreeSet::new();
731    collect_null_keys(&value, &mut nulls);
732    if !nulls.is_empty() {
733        warnings.push(format!(
734            "non-finite numeric values written as JSON null in field(s): {}",
735            nulls.into_iter().collect::<Vec<_>>().join(", ")
736        ));
737    }
738    let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
739    Conversion { text, warnings }
740}
741
742/// Collect the names of object keys whose value is `null`, anywhere in the tree.
743fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
744    match value {
745        Value::Object(map) => {
746            for (key, val) in map {
747                if val.is_null() {
748                    out.insert(key.clone());
749                } else {
750                    collect_null_keys(val, out);
751                }
752            }
753        }
754        Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
755        _ => {}
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use crate::network::SourceFormat;
763
764    #[test]
765    fn source_format_strings_round_trip_to_a_target() {
766        // The bindings expose `source_format` as its `{:?}` form, and
767        // `to_format` routes that string back through `target_format_from_name`.
768        // Every writable source format must resolve — including PowerModelsJson /
769        // EgretJson, whose camel-case names need the `powermodelsjson` /
770        // `egretjson` aliases (issue #75).
771        for (sf, want) in [
772            (SourceFormat::Matpower, TargetFormat::Matpower),
773            (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
774            (SourceFormat::EgretJson, TargetFormat::EgretJson),
775            (SourceFormat::Psse, TargetFormat::Psse),
776            (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
777            (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
778        ] {
779            let token = format!("{sf:?}");
780            assert_eq!(
781                target_format_from_name(&token),
782                Some(want),
783                "source_format {token:?} did not round-trip"
784            );
785        }
786        // The derived/in-memory source formats have no writer target, and
787        // neither does the read only .pwb binary.
788        for sf in [
789            SourceFormat::InMemory,
790            SourceFormat::Normalized,
791            SourceFormat::Gridfm,
792            SourceFormat::PypsaCsv,
793            SourceFormat::PowerWorldBinary,
794        ] {
795            assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
796        }
797    }
798}