1use std::collections::{BTreeSet, HashMap};
31use std::fmt;
32use std::str::FromStr;
33use std::sync::Arc;
34
35use serde_json::{Map, Value};
36
37use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
38use crate::network::{BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat};
39use crate::{Error, Result};
40use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
41
42mod egret;
43#[doc(hidden)]
44pub mod goc3;
45mod matpower;
46mod opfdata;
47mod pandapower;
48mod powermodels;
49pub mod powerworld;
50mod pslf;
51mod psse;
52mod pypsa;
53pub mod routing;
54mod surge;
55
56pub use egret::{parse_egret_json, write_egret_json};
57pub use goc3::parse_goc3_json;
58pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
59pub use opfdata::parse_deepmind_opfdata_json;
60pub use pandapower::{parse_pandapower_json, write_pandapower_json};
61pub use powermodels::{parse_powermodels_json, write_powermodels_json};
62pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
63pub use pslf::{parse_pslf, write_pslf};
64pub use psse::{parse_psse, write_psse, write_psse_rev};
65pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
66pub use surge::{parse_surge_json, write_surge_json};
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum TargetFormat {
72 PowerModelsJson,
74 EgretJson,
76 Psse { rev: u32 },
80 PowerWorld,
82 PandapowerJson,
84 Matpower,
86 #[doc(hidden)]
89 PowerioJson,
90 Pslf,
92 Goc3Json,
95 SurgeJson,
97 DeepMindOpfDataJson,
100}
101
102impl TargetFormat {
103 #[must_use]
105 pub fn extension(self) -> &'static str {
106 match self {
107 TargetFormat::PowerModelsJson
108 | TargetFormat::EgretJson
109 | TargetFormat::PandapowerJson
110 | TargetFormat::PowerioJson
111 | TargetFormat::Goc3Json
112 | TargetFormat::SurgeJson
113 | TargetFormat::DeepMindOpfDataJson => "json",
114 TargetFormat::Psse { .. } => "raw",
115 TargetFormat::PowerWorld => "aux",
116 TargetFormat::Matpower => "m",
117 TargetFormat::Pslf => "epc",
118 }
119 }
120
121 #[must_use]
123 pub fn label(self) -> &'static str {
124 match self {
125 TargetFormat::PowerModelsJson => "PowerModels JSON",
126 TargetFormat::EgretJson => "egret JSON",
127 TargetFormat::Psse { .. } => "PSS/E .raw",
128 TargetFormat::PowerWorld => "PowerWorld .aux",
129 TargetFormat::PandapowerJson => "pandapower JSON",
130 TargetFormat::Matpower => "MATPOWER .m",
131 TargetFormat::PowerioJson => "PowerIO JSON",
132 TargetFormat::Pslf => "PSLF .epc",
133 TargetFormat::Goc3Json => "GO Challenge 3 JSON",
134 TargetFormat::SurgeJson => "Surge JSON",
135 TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
136 }
137 }
138
139 #[must_use]
141 pub fn token(self) -> &'static str {
142 match self {
143 TargetFormat::PowerModelsJson => "powermodels-json",
144 TargetFormat::EgretJson => "egret-json",
145 TargetFormat::Psse { rev: 34 } => "psse34",
146 TargetFormat::Psse { rev: 35 } => "psse35",
147 TargetFormat::Psse { .. } => "psse",
148 TargetFormat::PowerWorld => "powerworld",
149 TargetFormat::PandapowerJson => "pandapower-json",
150 TargetFormat::Matpower => "matpower",
151 TargetFormat::PowerioJson => "powerio-json",
152 TargetFormat::Pslf => "pslf",
153 TargetFormat::Goc3Json => "goc3-json",
154 TargetFormat::SurgeJson => "surge-json",
155 TargetFormat::DeepMindOpfDataJson => "opfdata-json",
156 }
157 }
158}
159
160impl fmt::Display for TargetFormat {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.write_str(self.token())
163 }
164}
165
166impl FromStr for TargetFormat {
167 type Err = Error;
168
169 fn from_str(name: &str) -> Result<Self> {
170 target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum DisplayFormat {
179 PowerWorld,
181 GeoJson,
185}
186
187impl DisplayFormat {
188 #[must_use]
190 pub fn extension(self) -> &'static str {
191 match self {
192 DisplayFormat::PowerWorld => "pwd",
193 DisplayFormat::GeoJson => crate::geo::GEO_LAYER_EXTENSION,
194 }
195 }
196
197 #[must_use]
199 pub fn label(self) -> &'static str {
200 match self {
201 DisplayFormat::PowerWorld => "PowerWorld .pwd",
202 DisplayFormat::GeoJson => "geo layer",
203 }
204 }
205
206 #[must_use]
208 pub fn token(self) -> &'static str {
209 match self {
210 DisplayFormat::PowerWorld => "powerworld-display",
211 DisplayFormat::GeoJson => "geojson",
212 }
213 }
214}
215
216impl fmt::Display for DisplayFormat {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.write_str(self.token())
219 }
220}
221
222impl FromStr for DisplayFormat {
223 type Err = Error;
224
225 fn from_str(name: &str) -> Result<Self> {
226 display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
227 }
228}
229
230#[must_use]
234pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
235 Some(match name.to_ascii_lowercase().as_str() {
236 "pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
237 "geojson" | "geo-json" | "geo" => DisplayFormat::GeoJson,
238 _ => return None,
239 })
240}
241
242#[must_use]
259pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
260 Some(match routing::transmission_format_from_name(name)? {
261 TransmissionFormat::Matpower => TargetFormat::Matpower,
262 TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
263 TransmissionFormat::EgretJson => TargetFormat::EgretJson,
264 TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
265 TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
266 TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
267 TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
268 TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
269 TransmissionFormat::PowerioJson => TargetFormat::PowerioJson,
270 TransmissionFormat::Pslf => TargetFormat::Pslf,
271 TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
272 TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
273 TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
274 TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
275 return None;
276 }
277 })
278}
279
280#[derive(Debug, Clone, PartialEq)]
284#[non_exhaustive]
285pub enum DisplayData {
286 PowerWorld(PwdDisplay),
288 Geo(crate::geo::GeoLayer),
290}
291
292impl DisplayData {
293 #[must_use]
295 pub fn format(&self) -> DisplayFormat {
296 match self {
297 DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
298 DisplayData::Geo(_) => DisplayFormat::GeoJson,
299 }
300 }
301}
302
303fn display_file_guidance() -> Error {
304 Error::UnknownFormat(
305 "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
306 use parse_display_file(path, None)"
307 .into(),
308 )
309}
310
311pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
317 let fmt =
318 display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
319 match fmt {
320 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
321 bytes,
322 )?)),
323 DisplayFormat::GeoJson => Ok(DisplayData::Geo(
326 crate::geo::GeoLayer::parse_bytes(bytes, None)?.layer,
327 )),
328 }
329}
330
331pub fn parse_display_file(
340 path: impl AsRef<std::path::Path>,
341 from: Option<&str>,
342) -> Result<DisplayData> {
343 let path = path.as_ref();
344 let fmt = match from {
345 Some(f) => {
346 display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
347 }
348 None => match path
349 .extension()
350 .and_then(|e| e.to_str())
351 .map(str::to_ascii_lowercase)
352 .as_deref()
353 {
354 Some("pwd") => DisplayFormat::PowerWorld,
355 Some("geojson") => DisplayFormat::GeoJson,
356 Some("json")
359 if path
360 .file_name()
361 .and_then(|name| name.to_str())
362 .is_some_and(|name| {
363 name.to_ascii_lowercase()
364 .ends_with(crate::geo::GEO_LAYER_EXTENSION)
365 }) =>
366 {
367 DisplayFormat::GeoJson
368 }
369 other => {
370 return Err(Error::UnknownFormat(format!(
371 "cannot infer display format from file extension {other:?}; \
372 pass an explicit display format"
373 )));
374 }
375 },
376 };
377 let bytes = std::fs::read(path)?;
378 match fmt {
379 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
380 &bytes,
381 )?)),
382 DisplayFormat::GeoJson => Ok(DisplayData::Geo(
383 crate::geo::GeoLayer::parse_bytes(&bytes, path.file_name().and_then(|n| n.to_str()))?
384 .layer,
385 )),
386 }
387}
388
389fn is_pypsa_csv_name(name: &str) -> bool {
394 matches!(
395 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
396 "pypsacsv" | "pypsa"
397 )
398}
399
400fn is_pslf_name(name: &str) -> bool {
402 matches!(
403 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
404 "pslf" | "epc" | "pslfepc"
405 )
406}
407
408pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
436 let path = path.as_ref();
437 if from.is_some_and(is_pypsa_csv_name)
441 || (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
442 {
443 return pypsa::read_pypsa_csv_folder(path);
444 }
445 let ext = path
448 .extension()
449 .and_then(|e| e.to_str())
450 .map(str::to_ascii_lowercase);
451 if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
452 || (from.is_none() && ext.as_deref() == Some("pwb"))
453 {
454 let bytes = std::fs::read(path)?;
455 let stem = path.file_stem().and_then(|s| s.to_str());
456 let network = powerworld::parse_pwb(&bytes, stem)?;
459 return Ok(Parsed::without_document(network, Vec::new()));
460 }
461 if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
462 let text = std::fs::read_to_string(path)?;
463 let stem = path.file_stem().and_then(|s| s.to_str());
464 let mut warnings = Vec::new();
465 let source = strip_bom(Arc::new(text), &mut warnings);
466 let network = pslf::parse_pslf_source(source, stem, &mut warnings)?;
467 reject_empty_case(&network, "PSLF .epc")?;
468 return Ok(Parsed::without_document(network, warnings));
469 }
470 if from
471 .and_then(target_format_from_name)
472 .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
473 && matches!(ext.as_deref(), Some("pt" | "gz"))
474 {
475 return Err(Error::UnknownFormat(
476 "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
477 .into(),
478 ));
479 }
480 if from.is_none() && ext.as_deref() == Some("pwd") {
486 return Err(display_file_guidance());
487 }
488 let fmt_hint = match from {
489 Some(f) => {
490 if display_format_from_name(f).is_some() {
491 return Err(display_file_guidance());
492 }
493 Some(target_format_from_name(f).ok_or_else(|| unknown_source_format(f))?)
494 }
495 None => {
496 match ext.as_deref() {
498 Some("m") => Some(TargetFormat::Matpower),
499 Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
500 Some("aux") => Some(TargetFormat::PowerWorld),
501 Some("json") => None,
502 Some("dss") => return Err(unknown_source_format("dss")),
503 other => {
504 return Err(Error::UnknownFormat(format!(
505 "cannot infer from file extension {other:?}; \
506 pass an explicit source format"
507 )));
508 }
509 }
510 }
511 };
512 let text = std::fs::read_to_string(path)?;
516 let fmt = match fmt_hint {
517 Some(fmt) => fmt,
518 None => sniff_json(&text)?,
519 };
520 let stem = path.file_stem().and_then(|s| s.to_str());
522 read_source(Arc::new(text), fmt, stem)
523}
524
525fn strip_bom(source: Arc<String>, warnings: &mut Vec<String>) -> Arc<String> {
531 let Some(stripped) = source.strip_prefix('\u{feff}') else {
532 return source;
533 };
534 warnings.push(
535 "leading UTF-8 byte order mark removed; a same-format write returns the text without it"
536 .to_owned(),
537 );
538 Arc::new(stripped.to_owned())
539}
540
541fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
549 let mut warnings = Vec::new();
550 let source = strip_bom(source, &mut warnings);
551 let mut document = None;
552 let net = match fmt {
553 TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
554 TargetFormat::PowerModelsJson => {
555 powermodels::parse_powermodels_json_source(source, name_hint, &mut warnings)
556 }
557 TargetFormat::Psse { .. } => psse::parse_psse_source(source, name_hint, &mut warnings),
558 TargetFormat::PowerWorld => {
559 powerworld::parse_powerworld_source(source, name_hint, &mut warnings)
560 }
561 TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
562 TargetFormat::PandapowerJson => {
563 pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
564 }
565 TargetFormat::PowerioJson => BalancedNetwork::from_json(&source),
568 TargetFormat::Pslf => pslf::parse_pslf_source(source, name_hint, &mut warnings),
571 TargetFormat::Goc3Json => {
572 goc3::parse_goc3_source(source, name_hint, &mut warnings).map(|(net, goc3)| {
573 document = Some(SourceDocument::Goc3(goc3));
574 net
575 })
576 }
577 TargetFormat::SurgeJson => surge::parse_surge_source(source, name_hint, &mut warnings),
578 TargetFormat::DeepMindOpfDataJson => {
579 opfdata::parse_opfdata_source(source, name_hint, &mut warnings)
580 }
581 }?;
582 reject_empty_case(&net, fmt.label())?;
583 Ok(Parsed {
584 network: net,
585 warnings,
586 document,
587 })
588}
589
590pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
597 let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
598 located.peek()?;
599 let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
600 Some(crate::geo::GeoMeta {
601 space: if in_bounds {
602 crate::geo::CoordinateSpace::Geographic { crs: None }
603 } else {
604 crate::geo::CoordinateSpace::Unknown
605 },
606 kind: None,
607 })
608}
609
610pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
616 if net.buses.is_empty() {
617 return Err(Error::FormatRead {
618 format,
619 message: "case has no buses".into(),
620 });
621 }
622 Ok(())
623}
624
625fn unknown_source_format(name: &str) -> Error {
630 if let Some(dist) = routing::distribution_format_from_name(name) {
631 return Error::UnknownFormat(format!(
632 "`{}` is a distribution format, and this parser reads only balanced \
633 transmission formats; use the distribution surface (powerio_dist::parse_file, \
634 pio_dist_parse_file in C, or the format-routed parse_file in the bindings)",
635 dist.name()
636 ));
637 }
638 Error::UnknownFormat(name.to_string())
639}
640
641fn sniff_json(text: &str) -> Result<TargetFormat> {
645 match routing::classify_json_text(text) {
646 JsonClass::Package => Err(Error::UnknownFormat(
647 "JSON is a .pio.json package; read it with the package entry points \
648 (pio_package_parse_str in C, powerio.Package.from_json in Python, \
649 read_package in Julia)"
650 .into(),
651 )),
652 JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
653 transmission_json_target(format)
654 }
655 JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
656 Err(Error::UnknownFormat(format!(
657 "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
658 format.name()
659 )))
660 }
661 JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
662 "ambiguous JSON markers; pass an explicit source format".into(),
663 )),
664 JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
665 "cannot infer JSON format; pass an explicit source format".into(),
666 )),
667 }
668}
669
670fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
671 match format {
672 TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
673 TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
674 TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
675 TransmissionFormat::PowerioJson => Ok(TargetFormat::PowerioJson),
676 TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
677 TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
678 TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
679 other => Err(Error::UnknownFormat(format!(
680 "JSON classifier returned non-JSON transmission format `{}`",
681 other.name()
682 ))),
683 }
684}
685
686pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
694 parse_str_with_name(text, format, None)
695}
696
697pub fn parse_str_with_name(text: &str, format: &str, name_hint: Option<&str>) -> Result<Parsed> {
705 if is_pslf_name(format) {
706 let mut warnings = Vec::new();
707 let source = strip_bom(Arc::new(text.to_owned()), &mut warnings);
708 let network = pslf::parse_pslf_source(source, name_hint, &mut warnings)?;
709 reject_empty_case(&network, "PSLF .epc")?;
710 return Ok(Parsed::without_document(network, warnings));
711 }
712 let fmt = target_format_from_name(format).ok_or_else(|| unknown_source_format(format))?;
713 read_source(Arc::new(text.to_owned()), fmt, name_hint)
714}
715
716pub fn parse_bytes(bytes: &[u8], format: &str) -> Result<Parsed> {
730 parse_bytes_with_name(bytes, format, None)
731}
732
733pub fn parse_bytes_with_name(
739 bytes: &[u8],
740 format: &str,
741 name_hint: Option<&str>,
742) -> Result<Parsed> {
743 if format.eq_ignore_ascii_case("pwb") {
744 let network = powerworld::parse_pwb(bytes, name_hint)?;
746 return Ok(Parsed::without_document(network, Vec::new()));
747 }
748 if display_format_from_name(format).is_some() {
751 return Err(Error::UnknownFormat(format!(
752 "{format} is display data, not a BalancedNetwork case; \
753 use parse_display_bytes(bytes, \"{format}\")"
754 )));
755 }
756 let text = std::str::from_utf8(bytes).map_err(|e| Error::FormatRead {
757 format: "case text",
758 message: format!("not valid UTF-8: {e}"),
759 })?;
760 parse_str_with_name(text, format, name_hint)
761}
762
763#[derive(Debug, Clone)]
772#[non_exhaustive]
773pub struct Parsed {
774 pub network: BalancedNetwork,
775 pub warnings: Vec<String>,
776 pub document: Option<SourceDocument>,
779}
780
781impl Parsed {
782 pub(crate) fn without_document(network: BalancedNetwork, warnings: Vec<String>) -> Self {
784 Self {
785 network,
786 warnings,
787 document: None,
788 }
789 }
790}
791
792#[derive(Debug, Clone)]
797#[non_exhaustive]
798pub enum SourceDocument {
799 Goc3(Arc<goc3::Goc3Document>),
800}
801
802#[derive(Debug, Clone)]
812#[non_exhaustive]
813pub struct Conversion {
814 pub text: String,
815 pub warnings: Vec<String>,
816}
817
818#[derive(Debug, Clone, Default)]
824pub struct WriteOptions {
825 pub missing_gen_cost: MissingGenCostPolicy,
826 pub gen_cost_patches: Vec<GenCostPatch>,
827}
828
829impl WriteOptions {
830 #[must_use]
831 pub fn is_default(&self) -> bool {
832 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
833 }
834}
835
836pub fn write_as(net: &BalancedNetwork, format: TargetFormat) -> Result<Conversion> {
847 if is_echo(net, format) {
848 if let Some(src) = &net.source {
849 return Ok(Conversion {
850 text: src.to_string(),
851 warnings: Vec::new(),
852 });
853 }
854 }
855 let mut conv = match format {
856 TargetFormat::PowerModelsJson => write_powermodels_json(net),
857 TargetFormat::EgretJson => write_egret_json(net),
858 TargetFormat::Psse { rev } => write_psse_rev(net, rev),
859 TargetFormat::PowerWorld => write_powerworld(net),
860 TargetFormat::PandapowerJson => write_pandapower_json(net),
861 TargetFormat::Matpower => matpower::write_matpower_conversion(net),
865 TargetFormat::PowerioJson => {
872 return net.to_json().map(|text| Conversion {
873 text,
874 warnings: net
875 .non_finite_fields()
876 .into_iter()
877 .map(|path| {
878 format!(
879 "{path} is not finite; JSON has no Inf/NaN, so it is written as \
880 null and this snapshot will not read back as powerio-json"
881 )
882 })
883 .collect(),
884 });
885 }
886 TargetFormat::Pslf => write_pslf(net),
887 TargetFormat::SurgeJson => write_surge_json(net),
888 TargetFormat::Goc3Json => {
889 return Err(Error::WriteUnsupported {
890 format: "goc3-json",
891 });
892 }
893 TargetFormat::DeepMindOpfDataJson => {
894 return Err(Error::WriteUnsupported {
895 format: "opfdata-json",
896 });
897 }
898 };
899 warn_normalized_tap(net, format, &mut conv);
900 warn_missing_reference(net, format, &mut conv);
901 warn_dropped_frequency(net, format, &mut conv);
902 warn_dropped_locations(net, format, &mut conv);
903 warn_psse_downgrade(net, format, &mut conv);
904 warn_dropped_transformer_charging(net, format, &mut conv);
905 Ok(conv)
906}
907
908pub fn write_as_with_options(
911 net: &BalancedNetwork,
912 format: TargetFormat,
913 options: &WriteOptions,
914) -> Result<Conversion> {
915 if options.is_default() {
916 return write_as(net, format);
917 }
918
919 let mut working = net.clone();
920 let report =
921 working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
922 let mut policy_warnings = Vec::new();
923 if report.patched > 0 {
924 policy_warnings.push(format!(
925 "generator cost patch applied to {} generator(s)",
926 report.patched
927 ));
928 }
929 if report.synthesized > 0 {
930 policy_warnings.push(match options.missing_gen_cost {
931 MissingGenCostPolicy::Fill {
932 c2,
933 c1,
934 c0,
935 startup,
936 shutdown,
937 } => format!(
938 "generator cost synthesized for {} generator(s): model 2, ncost 3, \
939 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
940 report.synthesized
941 ),
942 _ => unreachable!("only Fill synthesizes costs"),
943 });
944 }
945 if report.patched > 0 || report.synthesized > 0 {
946 working.source = None;
947 }
948
949 let mut conv = write_as(&working, format)?;
950 policy_warnings.append(&mut conv.warnings);
951 conv.warnings = policy_warnings;
952 Ok(conv)
953}
954
955pub(super) fn allocate_circuit_id<K: Ord + Clone>(
961 preferred: Option<&str>,
962 key: K,
963 used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
964) -> String {
965 let taken = used.entry(key).or_default();
966 if let Some(id) = preferred {
967 if taken.insert(id.to_owned()) {
968 return id.to_owned();
969 }
970 }
971 let mut n = 1u32;
972 loop {
973 let candidate = n.to_string();
974 if taken.insert(candidate.clone()) {
975 return candidate;
976 }
977 n += 1;
978 }
979}
980
981fn warn_psse_downgrade(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
989 if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) =
990 (format, net.source_format, net.source.as_ref())
991 {
992 let src_rev = psse::header_rev(src);
993 if src_rev > rev {
994 conv.warnings.push(format!(
995 "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
996 the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
997 ));
998 }
999 }
1000}
1001
1002fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1007 let carries_frequency = matches!(
1008 format,
1009 TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
1010 );
1011 if carries_frequency {
1012 return;
1013 }
1014 if (net.base_frequency - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1015 conv.warnings.push(format!(
1016 "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1017 net.base_frequency,
1018 format.label(),
1019 crate::network::DEFAULT_BASE_FREQUENCY
1020 ));
1021 }
1022}
1023
1024fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1031 let carries_locations = matches!(
1032 format,
1033 TargetFormat::PowerWorld | TargetFormat::PandapowerJson
1034 );
1035 if carries_locations {
1036 return;
1037 }
1038 let n = net.buses.iter().filter(|b| b.location.is_some()).count();
1039 let routed = net.branches.iter().filter(|b| b.route.is_some()).count();
1040 if n > 0 || routed > 0 {
1041 conv.warnings.push(format!(
1042 "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1043 coordinate field (write a .geo.json sidecar to keep them)",
1044 format.label()
1045 ));
1046 }
1047}
1048
1049fn warn_dropped_transformer_charging(
1055 net: &BalancedNetwork,
1056 format: TargetFormat,
1057 conv: &mut Conversion,
1058) {
1059 if !matches!(format, TargetFormat::Pslf) {
1060 return;
1061 }
1062 let n = net
1063 .branches
1064 .iter()
1065 .filter(|b| b.is_transformer() && b.total_charging_b() != 0.0)
1066 .count();
1067 if n > 0 {
1068 conv.warnings.push(format!(
1069 "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1070 record cannot represent; the charging was dropped"
1071 ));
1072 }
1073}
1074
1075pub(super) fn branch_rating_set_drop_warning(
1076 target: &str,
1077 branch_index: usize,
1078 branch: &Branch,
1079 rating: &BranchRatingSet,
1080) -> String {
1081 format!(
1082 "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1083 branch_index + 1,
1084 branch.from,
1085 branch.to,
1086 rating.name,
1087 rating.rate_mva,
1088 target
1089 )
1090}
1091
1092pub(super) fn warn_extra_branch_rating_sets(
1093 target: &str,
1094 net: &BalancedNetwork,
1095 warnings: &mut Vec<String>,
1096) {
1097 for (branch_index, branch) in net.branches.iter().enumerate() {
1098 for rating in &branch.rating_sets {
1099 warnings.push(branch_rating_set_drop_warning(
1100 target,
1101 branch_index,
1102 branch,
1103 rating,
1104 ));
1105 }
1106 }
1107}
1108
1109pub fn convert_file(
1120 path: impl AsRef<std::path::Path>,
1121 to: TargetFormat,
1122 from: Option<&str>,
1123) -> Result<Conversion> {
1124 let parsed = parse_file(path, from)?;
1125 let mut conv = write_as(&parsed.network, to)?;
1126 if !is_echo(&parsed.network, to) {
1127 conv.warnings.splice(0..0, parsed.warnings);
1128 }
1129 Ok(conv)
1130}
1131
1132pub fn convert_file_with_options(
1134 path: impl AsRef<std::path::Path>,
1135 to: TargetFormat,
1136 from: Option<&str>,
1137 options: &WriteOptions,
1138) -> Result<Conversion> {
1139 let parsed = parse_file(path, from)?;
1140 let mut conv = write_as_with_options(&parsed.network, to, options)?;
1141 if !is_echo(&parsed.network, to) || !options.is_default() {
1142 conv.warnings.splice(0..0, parsed.warnings);
1143 }
1144 Ok(conv)
1145}
1146
1147pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
1157 let parsed = parse_str(text, format)?;
1158 let mut conv = write_as(&parsed.network, to)?;
1159 if !is_echo(&parsed.network, to) {
1160 conv.warnings.splice(0..0, parsed.warnings);
1161 }
1162 Ok(conv)
1163}
1164
1165pub fn convert_str_with_options(
1167 text: &str,
1168 to: TargetFormat,
1169 format: &str,
1170 options: &WriteOptions,
1171) -> Result<Conversion> {
1172 let parsed = parse_str(text, format)?;
1173 let mut conv = write_as_with_options(&parsed.network, to, options)?;
1174 if !is_echo(&parsed.network, to) || !options.is_default() {
1175 conv.warnings.splice(0..0, parsed.warnings);
1176 }
1177 Ok(conv)
1178}
1179
1180pub fn write_dir(
1190 net: &BalancedNetwork,
1191 to: &str,
1192 out_dir: impl AsRef<std::path::Path>,
1193) -> Result<Vec<String>> {
1194 if is_pypsa_csv_name(to) {
1195 return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings);
1196 }
1197 Err(Error::UnknownFormat(format!(
1198 "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
1199 text formats serialize through write_as / to_format"
1200 )))
1201}
1202
1203fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1209 let needs_ref = matches!(
1210 format,
1211 TargetFormat::Matpower
1212 | TargetFormat::Psse { .. }
1213 | TargetFormat::PowerModelsJson
1214 | TargetFormat::PandapowerJson
1215 | TargetFormat::Pslf
1216 | TargetFormat::SurgeJson
1217 );
1218 if needs_ref {
1219 conv.warnings.extend(missing_reference_warning(net));
1220 }
1221}
1222
1223pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
1227 (!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
1228 "no reference (slack) bus in the source network; power flow tools \
1229 reject such cases; to_normalized synthesizes a slack at the \
1230 largest pmax in service generator bus"
1231 .to_string()
1232 })
1233}
1234
1235#[allow(clippy::float_cmp)]
1247fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1248 if matches!(format, TargetFormat::Matpower) {
1249 return;
1250 }
1251 conv.warnings.extend(normalized_tap_warning(net));
1252}
1253
1254#[allow(clippy::float_cmp)]
1258pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
1259 if !net.is_normalized() {
1260 return None;
1261 }
1262 let ambiguous = net
1266 .branches
1267 .iter()
1268 .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1269 .count();
1270 (ambiguous > 0).then(|| {
1271 format!(
1272 "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1273 shift, so the line/transformer label is not preserved (the power flow \
1274 is identical)"
1275 )
1276 })
1277}
1278
1279fn nonzero_differs(value: f64, reference: f64) -> bool {
1283 value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1284}
1285
1286pub(crate) fn set_bus_kind(
1289 buses: &mut [Bus],
1290 bus_pos: &HashMap<BusId, usize>,
1291 bus: BusId,
1292 kind: BusType,
1293) {
1294 if let Some(&idx) = bus_pos.get(&bus) {
1295 if buses[idx].kind != BusType::Isolated {
1296 buses[idx].kind = kind;
1297 }
1298 }
1299}
1300
1301pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1303 bus_pos
1304 .get(&bus)
1305 .and_then(|&i| buses.get(i))
1306 .map_or(0.0, |b| b.base_kv)
1307}
1308
1309pub(crate) fn sanitize_quoted<'a>(
1327 value: &'a str,
1328 forbidden: &[char],
1329 replacement: char,
1330) -> std::borrow::Cow<'a, str> {
1331 let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
1332 if value.contains(breaks) {
1333 value
1334 .chars()
1335 .map(|c| if breaks(c) { replacement } else { c })
1336 .collect::<String>()
1337 .into()
1338 } else {
1339 std::borrow::Cow::Borrowed(value)
1340 }
1341}
1342
1343pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1346 if v_kv > 0.0 && base_mva > 0.0 {
1347 v_kv * v_kv / base_mva
1348 } else {
1349 1.0
1350 }
1351}
1352
1353fn is_echo(net: &BalancedNetwork, target: TargetFormat) -> bool {
1357 let Some(src) = &net.source else { return false };
1358 if !same_format(target, net.source_format) {
1359 return false;
1360 }
1361 if let TargetFormat::Psse { rev } = target {
1365 return psse::header_rev(src) == rev;
1366 }
1367 true
1368}
1369
1370fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1372 matches!(
1373 (target, source),
1374 (TargetFormat::Matpower, SourceFormat::Matpower)
1375 | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1376 | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1377 | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1378 | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1379 | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1380 | (TargetFormat::Pslf, SourceFormat::Pslf)
1381 | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1382 | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1383 | (
1384 TargetFormat::DeepMindOpfDataJson,
1385 SourceFormat::DeepMindOpfDataJson,
1386 )
1387 )
1388}
1389
1390pub(crate) fn jnum(x: f64) -> Value {
1392 serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1393}
1394
1395pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
1399 let value = Value::Object(root);
1400 let mut nulls = BTreeSet::new();
1401 collect_null_keys(&value, &mut nulls);
1402 if !nulls.is_empty() {
1403 warnings.push(format!(
1404 "non-finite numeric values written as JSON null in field(s): {}",
1405 nulls.into_iter().collect::<Vec<_>>().join(", ")
1406 ));
1407 }
1408 let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1409 Conversion { text, warnings }
1410}
1411
1412fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1414 match value {
1415 Value::Object(map) => {
1416 for (key, val) in map {
1417 if val.is_null() {
1418 out.insert(key.clone());
1419 } else {
1420 collect_null_keys(val, out);
1421 }
1422 }
1423 }
1424 Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1425 _ => {}
1426 }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::*;
1432 use crate::network::SourceFormat;
1433
1434 #[test]
1435 fn sanitize_quoted_always_replaces_line_terminators() {
1436 for forbidden in [&[][..], &['\''][..], &['"'][..]] {
1440 let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
1441 assert!(
1442 !out.contains('\n') && !out.contains('\r'),
1443 "terminator survived with forbidden={forbidden:?}: {out:?}"
1444 );
1445 }
1446 assert!(matches!(
1448 sanitize_quoted("clean name", &['\''], ' '),
1449 std::borrow::Cow::Borrowed(_)
1450 ));
1451 }
1452
1453 #[test]
1454 fn dss_extension_error_names_the_distribution_surface() {
1455 let err = parse_file("feeder.dss", None).unwrap_err();
1456 assert!(err.to_string().contains("distribution"), "got: {err}");
1457 }
1458
1459 #[test]
1460 fn distribution_from_token_error_names_the_distribution_surface() {
1461 for token in ["dss", "pmd", "bmopf"] {
1462 let err = parse_str("anything", token).unwrap_err();
1463 assert!(
1464 err.to_string().contains("distribution surface"),
1465 "{token}: {err}"
1466 );
1467 }
1468 let err = parse_str("anything", "nonesuch").unwrap_err();
1470 assert!(err.to_string().contains("nonesuch"));
1471 }
1472
1473 #[test]
1474 fn byte_order_mark_is_stripped_and_warned() {
1475 let case = "\u{feff}function mpc = t\n\
1476 mpc.version = '2';\n\
1477 mpc.baseMVA = 100;\n\
1478 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
1479 mpc.gen = [];\n\
1480 mpc.branch = [];\n";
1481 let parsed = parse_str(case, "matpower").unwrap();
1482 assert_eq!(parsed.network.buses.len(), 1);
1483 assert!(
1484 parsed
1485 .warnings
1486 .iter()
1487 .any(|w| w.contains("byte order mark")),
1488 "warnings: {:?}",
1489 parsed.warnings
1490 );
1491 }
1492
1493 #[test]
1494 fn package_json_error_names_the_package_reader() {
1495 let err = sniff_json(r#"{"model_kind":"balanced","model":{}}"#).unwrap_err();
1496 assert!(err.to_string().contains(".pio.json"), "got: {err}");
1497 }
1498
1499 #[test]
1500 fn source_format_strings_round_trip_to_a_target() {
1501 for (sf, want) in [
1507 (SourceFormat::Matpower, TargetFormat::Matpower),
1508 (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
1509 (SourceFormat::EgretJson, TargetFormat::EgretJson),
1510 (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
1511 (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
1512 (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
1513 (SourceFormat::Pslf, TargetFormat::Pslf),
1514 (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
1515 (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
1516 (
1517 SourceFormat::DeepMindOpfDataJson,
1518 TargetFormat::DeepMindOpfDataJson,
1519 ),
1520 ] {
1521 let token = format!("{sf:?}");
1522 assert_eq!(
1523 target_format_from_name(&token),
1524 Some(want),
1525 "source_format {token:?} did not round-trip"
1526 );
1527 }
1528 for sf in [
1531 SourceFormat::InMemory,
1532 SourceFormat::Normalized,
1533 SourceFormat::Gridfm,
1534 SourceFormat::PypsaCsv,
1535 SourceFormat::PowerWorldBinary,
1536 ] {
1537 assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
1538 }
1539 }
1540}