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}