1use std::collections::{BTreeSet, HashMap};
30use std::fmt;
31use std::str::FromStr;
32use std::sync::Arc;
33
34use serde_json::{Map, Value};
35
36use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
37use crate::network::{Branch, BranchRatingSet, Bus, BusId, BusType, Network, SourceFormat};
38use crate::{Error, Result};
39use routing::{Detection, SourceFormat as DetectedFormat, TransmissionFormat};
40
41mod egret;
42mod goc3;
43mod matpower;
44mod pandapower;
45mod powermodels;
46pub mod powerworld;
47mod pslf;
48mod psse;
49mod pypsa;
50pub mod routing;
51mod surge;
52
53pub use egret::{parse_egret_json, write_egret_json};
54#[doc(hidden)]
55pub use goc3::bridge as goc3_bridge;
56pub use goc3::parse_goc3_json;
57pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
58pub use pandapower::{parse_pandapower_json, write_pandapower_json};
59pub use powermodels::{parse_powermodels_json, write_powermodels_json};
60pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
61pub use pslf::{parse_pslf, write_pslf};
62pub use psse::{parse_psse, write_psse, write_psse_rev};
63pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
64pub use surge::{parse_surge_json, write_surge_json};
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum TargetFormat {
70 PowerModelsJson,
72 EgretJson,
74 Psse { rev: u32 },
78 PowerWorld,
80 PandapowerJson,
82 Matpower,
84 PowerioJson,
88 Pslf,
90 Goc3Json,
93 SurgeJson,
95}
96
97impl TargetFormat {
98 #[must_use]
100 pub fn extension(self) -> &'static str {
101 match self {
102 TargetFormat::PowerModelsJson
103 | TargetFormat::EgretJson
104 | TargetFormat::PandapowerJson
105 | TargetFormat::PowerioJson
106 | TargetFormat::Goc3Json
107 | TargetFormat::SurgeJson => "json",
108 TargetFormat::Psse { .. } => "raw",
109 TargetFormat::PowerWorld => "aux",
110 TargetFormat::Matpower => "m",
111 TargetFormat::Pslf => "epc",
112 }
113 }
114
115 #[must_use]
117 pub fn label(self) -> &'static str {
118 match self {
119 TargetFormat::PowerModelsJson => "PowerModels JSON",
120 TargetFormat::EgretJson => "egret JSON",
121 TargetFormat::Psse { .. } => "PSS/E .raw",
122 TargetFormat::PowerWorld => "PowerWorld .aux",
123 TargetFormat::PandapowerJson => "pandapower JSON",
124 TargetFormat::Matpower => "MATPOWER .m",
125 TargetFormat::PowerioJson => "PowerIO JSON",
126 TargetFormat::Pslf => "PSLF .epc",
127 TargetFormat::Goc3Json => "GO Challenge 3 JSON",
128 TargetFormat::SurgeJson => "Surge JSON",
129 }
130 }
131
132 #[must_use]
134 pub fn token(self) -> &'static str {
135 match self {
136 TargetFormat::PowerModelsJson => "powermodels-json",
137 TargetFormat::EgretJson => "egret-json",
138 TargetFormat::Psse { rev: 34 } => "psse34",
139 TargetFormat::Psse { rev: 35 } => "psse35",
140 TargetFormat::Psse { .. } => "psse",
141 TargetFormat::PowerWorld => "powerworld",
142 TargetFormat::PandapowerJson => "pandapower-json",
143 TargetFormat::Matpower => "matpower",
144 TargetFormat::PowerioJson => "powerio-json",
145 TargetFormat::Pslf => "pslf",
146 TargetFormat::Goc3Json => "goc3-json",
147 TargetFormat::SurgeJson => "surge-json",
148 }
149 }
150}
151
152impl fmt::Display for TargetFormat {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 f.write_str(self.token())
155 }
156}
157
158impl FromStr for TargetFormat {
159 type Err = Error;
160
161 fn from_str(name: &str) -> Result<Self> {
162 target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169#[non_exhaustive]
170pub enum DisplayFormat {
171 PowerWorld,
173}
174
175impl DisplayFormat {
176 #[must_use]
178 pub fn extension(self) -> &'static str {
179 match self {
180 DisplayFormat::PowerWorld => "pwd",
181 }
182 }
183
184 #[must_use]
186 pub fn label(self) -> &'static str {
187 match self {
188 DisplayFormat::PowerWorld => "PowerWorld .pwd",
189 }
190 }
191
192 #[must_use]
194 pub fn token(self) -> &'static str {
195 match self {
196 DisplayFormat::PowerWorld => "powerworld-display",
197 }
198 }
199}
200
201impl fmt::Display for DisplayFormat {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.write_str(self.token())
204 }
205}
206
207impl FromStr for DisplayFormat {
208 type Err = Error;
209
210 fn from_str(name: &str) -> Result<Self> {
211 display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
212 }
213}
214
215#[must_use]
218pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
219 Some(match name.to_ascii_lowercase().as_str() {
220 "pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
221 _ => return None,
222 })
223}
224
225#[must_use]
241pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
242 Some(match routing::transmission_format_from_name(name)? {
243 TransmissionFormat::Matpower => TargetFormat::Matpower,
244 TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
245 TransmissionFormat::EgretJson => TargetFormat::EgretJson,
246 TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
247 TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
248 TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
249 TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
250 TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
251 TransmissionFormat::PowerioJson => TargetFormat::PowerioJson,
252 TransmissionFormat::Pslf => TargetFormat::Pslf,
253 TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
254 TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
255 TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
256 return None;
257 }
258 })
259}
260
261#[derive(Debug, Clone, PartialEq)]
264#[non_exhaustive]
265pub enum DisplayData {
266 PowerWorld(PwdDisplay),
268}
269
270impl DisplayData {
271 #[must_use]
273 pub fn format(&self) -> DisplayFormat {
274 match self {
275 DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
276 }
277 }
278}
279
280fn display_file_guidance() -> Error {
281 Error::UnknownFormat(
282 "a PowerWorld .pwd is display data, not a Network case; \
283 use parse_display_file(path, None)"
284 .into(),
285 )
286}
287
288pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
294 let fmt =
295 display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
296 match fmt {
297 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
298 bytes,
299 )?)),
300 }
301}
302
303pub fn parse_display_file(
311 path: impl AsRef<std::path::Path>,
312 from: Option<&str>,
313) -> Result<DisplayData> {
314 let path = path.as_ref();
315 let fmt = match from {
316 Some(f) => {
317 display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
318 }
319 None => match path
320 .extension()
321 .and_then(|e| e.to_str())
322 .map(str::to_ascii_lowercase)
323 .as_deref()
324 {
325 Some("pwd") => DisplayFormat::PowerWorld,
326 other => {
327 return Err(Error::UnknownFormat(format!(
328 "cannot infer display format from file extension {other:?}; \
329 pass an explicit display format"
330 )));
331 }
332 },
333 };
334 let bytes = std::fs::read(path)?;
335 match fmt {
336 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
337 &bytes,
338 )?)),
339 }
340}
341
342fn is_pypsa_csv_name(name: &str) -> bool {
347 matches!(
348 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
349 "pypsacsv" | "pypsa"
350 )
351}
352
353fn is_pslf_name(name: &str) -> bool {
355 matches!(
356 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
357 "pslf" | "epc" | "pslfepc"
358 )
359}
360
361pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
388 let path = path.as_ref();
389 if from.is_some_and(is_pypsa_csv_name)
393 || (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
394 {
395 return pypsa::read_pypsa_csv_folder(path);
396 }
397 let ext = path
400 .extension()
401 .and_then(|e| e.to_str())
402 .map(str::to_ascii_lowercase);
403 if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
404 || (from.is_none() && ext.as_deref() == Some("pwb"))
405 {
406 let bytes = std::fs::read(path)?;
407 let stem = path.file_stem().and_then(|s| s.to_str());
408 let network = powerworld::parse_pwb(&bytes, stem)?;
411 return Ok(Parsed {
412 network,
413 warnings: Vec::new(),
414 });
415 }
416 if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
417 let text = std::fs::read_to_string(path)?;
418 let stem = path.file_stem().and_then(|s| s.to_str());
419 let mut warnings = Vec::new();
420 let network = pslf::parse_pslf_source(Arc::new(text), stem, &mut warnings)?;
421 reject_empty_case(&network, "PSLF .epc")?;
422 return Ok(Parsed { network, warnings });
423 }
424 if from.is_none() && ext.as_deref() == Some("pwd") {
430 return Err(display_file_guidance());
431 }
432 let fmt_hint = match from {
433 Some(f) => {
434 if display_format_from_name(f).is_some() {
435 return Err(display_file_guidance());
436 }
437 Some(target_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?)
438 }
439 None => {
440 match ext.as_deref() {
442 Some("m") => Some(TargetFormat::Matpower),
443 Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
444 Some("aux") => Some(TargetFormat::PowerWorld),
445 Some("json") => None,
446 other => {
447 return Err(Error::UnknownFormat(format!(
448 "cannot infer from file extension {other:?}; \
449 pass an explicit source format"
450 )));
451 }
452 }
453 }
454 };
455 let text = std::fs::read_to_string(path)?;
459 let fmt = match fmt_hint {
460 Some(fmt) => fmt,
461 None => sniff_json(&text)?,
462 };
463 let stem = path.file_stem().and_then(|s| s.to_str());
465 read_source(Arc::new(text), fmt, stem)
466}
467
468fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
476 let mut warnings = Vec::new();
477 let net = match fmt {
478 TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
479 TargetFormat::PowerModelsJson => {
480 powermodels::parse_powermodels_json_source(source, name_hint, &mut warnings)
481 }
482 TargetFormat::Psse { .. } => psse::parse_psse_source(source, name_hint, &mut warnings),
483 TargetFormat::PowerWorld => {
484 powerworld::parse_powerworld_source(source, name_hint, &mut warnings)
485 }
486 TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
487 TargetFormat::PandapowerJson => {
488 pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
489 }
490 TargetFormat::PowerioJson => Network::from_json(&source),
493 TargetFormat::Pslf => pslf::parse_pslf_source(source, name_hint, &mut warnings),
496 TargetFormat::Goc3Json => goc3::parse_goc3_source(source, name_hint, &mut warnings),
497 TargetFormat::SurgeJson => surge::parse_surge_source(source, name_hint, &mut warnings),
498 }?;
499 reject_empty_case(&net, fmt.label())?;
500 Ok(Parsed {
501 network: net,
502 warnings,
503 })
504}
505
506pub(crate) fn reject_empty_case(net: &Network, format: &'static str) -> Result<()> {
512 if net.buses.is_empty() {
513 return Err(Error::FormatRead {
514 format,
515 message: "case has no buses".into(),
516 });
517 }
518 Ok(())
519}
520
521fn sniff_json(text: &str) -> Result<TargetFormat> {
525 match routing::classify_json_text(text) {
526 Detection::Known(DetectedFormat::Transmission(format)) => transmission_json_target(format),
527 Detection::Known(DetectedFormat::Distribution(format)) => {
528 Err(Error::UnknownFormat(format!(
529 "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
530 format.name()
531 )))
532 }
533 Detection::Ambiguous => Err(Error::UnknownFormat(
534 "ambiguous JSON markers; pass an explicit source format".into(),
535 )),
536 Detection::Unknown => Err(Error::UnknownFormat(
537 "cannot infer JSON format; pass an explicit source format".into(),
538 )),
539 }
540}
541
542fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
543 match format {
544 TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
545 TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
546 TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
547 TransmissionFormat::PowerioJson => Ok(TargetFormat::PowerioJson),
548 TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
549 TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
550 other => Err(Error::UnknownFormat(format!(
551 "JSON classifier returned non-JSON transmission format `{}`",
552 other.name()
553 ))),
554 }
555}
556
557pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
565 if is_pslf_name(format) {
566 let mut warnings = Vec::new();
567 let network = pslf::parse_pslf_source(Arc::new(text.to_owned()), None, &mut warnings)?;
568 reject_empty_case(&network, "PSLF .epc")?;
569 return Ok(Parsed { network, warnings });
570 }
571 let fmt =
572 target_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
573 read_source(Arc::new(text.to_owned()), fmt, None)
574}
575
576#[derive(Debug, Clone)]
585#[non_exhaustive]
586pub struct Parsed {
587 pub network: Network,
588 pub warnings: Vec<String>,
589}
590
591#[derive(Debug, Clone)]
601#[non_exhaustive]
602pub struct Conversion {
603 pub text: String,
604 pub warnings: Vec<String>,
605}
606
607#[derive(Debug, Clone, Default)]
613pub struct WriteOptions {
614 pub missing_gen_cost: MissingGenCostPolicy,
615 pub gen_cost_patches: Vec<GenCostPatch>,
616}
617
618impl WriteOptions {
619 #[must_use]
620 pub fn is_default(&self) -> bool {
621 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
622 }
623}
624
625pub fn write_as(net: &Network, format: TargetFormat) -> Result<Conversion> {
636 if is_echo(net, format) {
637 if let Some(src) = &net.source {
638 return Ok(Conversion {
639 text: src.to_string(),
640 warnings: Vec::new(),
641 });
642 }
643 }
644 let mut conv = match format {
645 TargetFormat::PowerModelsJson => write_powermodels_json(net),
646 TargetFormat::EgretJson => write_egret_json(net),
647 TargetFormat::Psse { rev } => write_psse_rev(net, rev),
648 TargetFormat::PowerWorld => write_powerworld(net),
649 TargetFormat::PandapowerJson => write_pandapower_json(net),
650 TargetFormat::Matpower => matpower::write_matpower_conversion(net),
654 TargetFormat::PowerioJson => {
661 return net.to_json().map(|text| Conversion {
662 text,
663 warnings: net
664 .non_finite_fields()
665 .into_iter()
666 .map(|path| {
667 format!(
668 "{path} is not finite; JSON has no Inf/NaN, so it is written as \
669 null and this snapshot will not read back as powerio-json"
670 )
671 })
672 .collect(),
673 });
674 }
675 TargetFormat::Pslf => write_pslf(net),
676 TargetFormat::SurgeJson => write_surge_json(net),
677 TargetFormat::Goc3Json => {
678 return Err(Error::WriteUnsupported {
679 format: "goc3-json",
680 });
681 }
682 };
683 warn_normalized_tap(net, format, &mut conv);
684 warn_missing_reference(net, format, &mut conv);
685 warn_dropped_frequency(net, format, &mut conv);
686 warn_psse_downgrade(net, format, &mut conv);
687 warn_dropped_transformer_charging(net, format, &mut conv);
688 Ok(conv)
689}
690
691pub fn write_as_with_options(
694 net: &Network,
695 format: TargetFormat,
696 options: &WriteOptions,
697) -> Result<Conversion> {
698 if options.is_default() {
699 return write_as(net, format);
700 }
701
702 let mut working = net.clone();
703 let report =
704 working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
705 let mut policy_warnings = Vec::new();
706 if report.patched > 0 {
707 policy_warnings.push(format!(
708 "generator cost patch applied to {} generator(s)",
709 report.patched
710 ));
711 }
712 if report.synthesized > 0 {
713 policy_warnings.push(match options.missing_gen_cost {
714 MissingGenCostPolicy::Fill {
715 c2,
716 c1,
717 c0,
718 startup,
719 shutdown,
720 } => format!(
721 "generator cost synthesized for {} generator(s): model 2, ncost 3, \
722 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
723 report.synthesized
724 ),
725 _ => unreachable!("only Fill synthesizes costs"),
726 });
727 }
728 if report.patched > 0 || report.synthesized > 0 {
729 working.source = None;
730 }
731
732 let mut conv = write_as(&working, format)?;
733 policy_warnings.append(&mut conv.warnings);
734 conv.warnings = policy_warnings;
735 Ok(conv)
736}
737
738pub(super) fn allocate_circuit_id<K: Ord + Clone>(
744 preferred: Option<&str>,
745 key: K,
746 used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
747) -> String {
748 let taken = used.entry(key).or_default();
749 if let Some(id) = preferred {
750 if taken.insert(id.to_owned()) {
751 return id.to_owned();
752 }
753 }
754 let mut n = 1u32;
755 loop {
756 let candidate = n.to_string();
757 if taken.insert(candidate.clone()) {
758 return candidate;
759 }
760 n += 1;
761 }
762}
763
764fn warn_psse_downgrade(net: &Network, format: TargetFormat, conv: &mut Conversion) {
772 if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) =
773 (format, net.source_format, net.source.as_ref())
774 {
775 let src_rev = psse::header_rev(src);
776 if src_rev > rev {
777 conv.warnings.push(format!(
778 "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
779 the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
780 ));
781 }
782 }
783}
784
785fn warn_dropped_frequency(net: &Network, format: TargetFormat, conv: &mut Conversion) {
790 let carries_frequency = matches!(
791 format,
792 TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
793 );
794 if carries_frequency {
795 return;
796 }
797 if (net.base_frequency - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
798 conv.warnings.push(format!(
799 "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
800 net.base_frequency,
801 format.label(),
802 crate::network::DEFAULT_BASE_FREQUENCY
803 ));
804 }
805}
806
807fn warn_dropped_transformer_charging(net: &Network, format: TargetFormat, conv: &mut Conversion) {
813 if !matches!(format, TargetFormat::Pslf) {
814 return;
815 }
816 let n = net
817 .branches
818 .iter()
819 .filter(|b| b.is_transformer() && b.legacy_total_charging_b() != 0.0)
820 .count();
821 if n > 0 {
822 conv.warnings.push(format!(
823 "{n} transformer(s) carry line charging that the PSLF .epc transformer \
824 record cannot represent; the charging was dropped"
825 ));
826 }
827}
828
829pub(super) fn branch_rating_set_drop_warning(
830 target: &str,
831 branch_index: usize,
832 branch: &Branch,
833 rating: &BranchRatingSet,
834) -> String {
835 format!(
836 "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
837 branch_index + 1,
838 branch.from,
839 branch.to,
840 rating.name,
841 rating.rate_mva,
842 target
843 )
844}
845
846pub(super) fn warn_extra_branch_rating_sets(
847 target: &str,
848 net: &Network,
849 warnings: &mut Vec<String>,
850) {
851 for (branch_index, branch) in net.branches.iter().enumerate() {
852 for rating in &branch.rating_sets {
853 warnings.push(branch_rating_set_drop_warning(
854 target,
855 branch_index,
856 branch,
857 rating,
858 ));
859 }
860 }
861}
862
863pub fn convert_file(
874 path: impl AsRef<std::path::Path>,
875 to: TargetFormat,
876 from: Option<&str>,
877) -> Result<Conversion> {
878 let parsed = parse_file(path, from)?;
879 let mut conv = write_as(&parsed.network, to)?;
880 if !is_echo(&parsed.network, to) {
881 conv.warnings.splice(0..0, parsed.warnings);
882 }
883 Ok(conv)
884}
885
886pub fn convert_file_with_options(
888 path: impl AsRef<std::path::Path>,
889 to: TargetFormat,
890 from: Option<&str>,
891 options: &WriteOptions,
892) -> Result<Conversion> {
893 let parsed = parse_file(path, from)?;
894 let mut conv = write_as_with_options(&parsed.network, to, options)?;
895 if !is_echo(&parsed.network, to) || !options.is_default() {
896 conv.warnings.splice(0..0, parsed.warnings);
897 }
898 Ok(conv)
899}
900
901pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
911 let parsed = parse_str(text, format)?;
912 let mut conv = write_as(&parsed.network, to)?;
913 if !is_echo(&parsed.network, to) {
914 conv.warnings.splice(0..0, parsed.warnings);
915 }
916 Ok(conv)
917}
918
919pub fn convert_str_with_options(
921 text: &str,
922 to: TargetFormat,
923 format: &str,
924 options: &WriteOptions,
925) -> Result<Conversion> {
926 let parsed = parse_str(text, format)?;
927 let mut conv = write_as_with_options(&parsed.network, to, options)?;
928 if !is_echo(&parsed.network, to) || !options.is_default() {
929 conv.warnings.splice(0..0, parsed.warnings);
930 }
931 Ok(conv)
932}
933
934pub fn write_dir(
944 net: &Network,
945 to: &str,
946 out_dir: impl AsRef<std::path::Path>,
947) -> Result<Vec<String>> {
948 if is_pypsa_csv_name(to) {
949 return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings);
950 }
951 Err(Error::UnknownFormat(format!(
952 "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
953 text formats serialize through write_as / to_format"
954 )))
955}
956
957fn warn_missing_reference(net: &Network, format: TargetFormat, conv: &mut Conversion) {
963 let needs_ref = matches!(
964 format,
965 TargetFormat::Matpower
966 | TargetFormat::Psse { .. }
967 | TargetFormat::PowerModelsJson
968 | TargetFormat::PandapowerJson
969 | TargetFormat::Pslf
970 | TargetFormat::SurgeJson
971 );
972 if needs_ref {
973 conv.warnings.extend(missing_reference_warning(net));
974 }
975}
976
977pub(super) fn missing_reference_warning(net: &Network) -> Option<String> {
981 (!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
982 "no reference (slack) bus in the source network; power flow tools \
983 reject such cases; to_normalized synthesizes a slack at the \
984 largest pmax in service generator bus"
985 .to_string()
986 })
987}
988
989#[allow(clippy::float_cmp)]
1001fn warn_normalized_tap(net: &Network, format: TargetFormat, conv: &mut Conversion) {
1002 if matches!(format, TargetFormat::Matpower) {
1003 return;
1004 }
1005 conv.warnings.extend(normalized_tap_warning(net));
1006}
1007
1008#[allow(clippy::float_cmp)]
1012pub(super) fn normalized_tap_warning(net: &Network) -> Option<String> {
1013 if !net.is_normalized() {
1014 return None;
1015 }
1016 let ambiguous = net
1020 .branches
1021 .iter()
1022 .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1023 .count();
1024 (ambiguous > 0).then(|| {
1025 format!(
1026 "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1027 shift, so the line/transformer label is not preserved (the power flow \
1028 is identical)"
1029 )
1030 })
1031}
1032
1033fn nonzero_differs(value: f64, reference: f64) -> bool {
1037 value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1038}
1039
1040pub(crate) fn set_bus_kind(
1043 buses: &mut [Bus],
1044 bus_pos: &HashMap<BusId, usize>,
1045 bus: BusId,
1046 kind: BusType,
1047) {
1048 if let Some(&idx) = bus_pos.get(&bus) {
1049 if buses[idx].kind != BusType::Isolated {
1050 buses[idx].kind = kind;
1051 }
1052 }
1053}
1054
1055pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1057 bus_pos
1058 .get(&bus)
1059 .and_then(|&i| buses.get(i))
1060 .map_or(0.0, |b| b.base_kv)
1061}
1062
1063pub(crate) fn sanitize_quoted<'a>(
1076 value: &'a str,
1077 forbidden: &[char],
1078 replacement: char,
1079) -> std::borrow::Cow<'a, str> {
1080 if value.contains(forbidden) {
1081 value
1082 .chars()
1083 .map(|c| {
1084 if forbidden.contains(&c) {
1085 replacement
1086 } else {
1087 c
1088 }
1089 })
1090 .collect::<String>()
1091 .into()
1092 } else {
1093 std::borrow::Cow::Borrowed(value)
1094 }
1095}
1096
1097pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1100 if v_kv > 0.0 && base_mva > 0.0 {
1101 v_kv * v_kv / base_mva
1102 } else {
1103 1.0
1104 }
1105}
1106
1107fn is_echo(net: &Network, target: TargetFormat) -> bool {
1111 let Some(src) = &net.source else { return false };
1112 if !same_format(target, net.source_format) {
1113 return false;
1114 }
1115 if let TargetFormat::Psse { rev } = target {
1119 return psse::header_rev(src) == rev;
1120 }
1121 true
1122}
1123
1124fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1126 matches!(
1127 (target, source),
1128 (TargetFormat::Matpower, SourceFormat::Matpower)
1129 | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1130 | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1131 | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1132 | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1133 | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1134 | (TargetFormat::Pslf, SourceFormat::Pslf)
1135 | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1136 | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1137 )
1138}
1139
1140pub(crate) fn jnum(x: f64) -> Value {
1142 serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1143}
1144
1145pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
1149 let value = Value::Object(root);
1150 let mut nulls = BTreeSet::new();
1151 collect_null_keys(&value, &mut nulls);
1152 if !nulls.is_empty() {
1153 warnings.push(format!(
1154 "non-finite numeric values written as JSON null in field(s): {}",
1155 nulls.into_iter().collect::<Vec<_>>().join(", ")
1156 ));
1157 }
1158 let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1159 Conversion { text, warnings }
1160}
1161
1162fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1164 match value {
1165 Value::Object(map) => {
1166 for (key, val) in map {
1167 if val.is_null() {
1168 out.insert(key.clone());
1169 } else {
1170 collect_null_keys(val, out);
1171 }
1172 }
1173 }
1174 Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1175 _ => {}
1176 }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 use crate::network::SourceFormat;
1183
1184 #[test]
1185 fn source_format_strings_round_trip_to_a_target() {
1186 for (sf, want) in [
1192 (SourceFormat::Matpower, TargetFormat::Matpower),
1193 (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
1194 (SourceFormat::EgretJson, TargetFormat::EgretJson),
1195 (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
1196 (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
1197 (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
1198 (SourceFormat::Pslf, TargetFormat::Pslf),
1199 (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
1200 (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
1201 ] {
1202 let token = format!("{sf:?}");
1203 assert_eq!(
1204 target_format_from_name(&token),
1205 Some(want),
1206 "source_format {token:?} did not round-trip"
1207 );
1208 }
1209 for sf in [
1212 SourceFormat::InMemory,
1213 SourceFormat::Normalized,
1214 SourceFormat::Gridfm,
1215 SourceFormat::PypsaCsv,
1216 SourceFormat::PowerWorldBinary,
1217 ] {
1218 assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
1219 }
1220 }
1221}