1use std::collections::{BTreeSet, HashMap};
32use std::fmt;
33use std::str::FromStr;
34
35use serde_json::{Map, Value};
36
37use powerio_core::{PioModule, SourceDescriptor};
38
39use crate::diagnostics::{Diagnostic, DiagnosticInfo, Diagnostics, EmitFamily, codes};
40use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
41use crate::network::{BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat};
42use crate::{Error, Result};
43use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
44
45mod decode;
46mod egret;
47#[doc(hidden)]
48pub mod goc3;
49mod matpower;
50mod opfdata;
51mod pandapower;
52mod powermodels;
53pub mod powerworld;
54mod pslf;
55mod psse;
56mod pypsa;
57pub mod routing;
58mod surge;
59
60pub use egret::{egret_declares_time_series, parse_egret_time_series, write_egret_json};
61pub use goc3::parse_goc3_json;
62pub use matpower::write_matpower;
63pub use opfdata::{OpfDataSolution, parse_opfdata_json};
64pub use pandapower::write_pandapower_json;
65pub use powermodels::write_powermodels_json;
66pub use powerworld::{PwdDisplay, PwdSubstation, write_powerworld};
67pub use pslf::write_pslf;
68pub use psse::{write_psse, write_psse_rev};
69pub use pypsa::{
70 PypsaAxis, PypsaCsvOutputs, PypsaCsvSequence, parse_pypsa_csv_time_series, pypsa_axis,
71 write_pypsa_csv_folder,
72};
73pub use surge::write_surge_json;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum TargetFormat {
79 PowerModelsJson,
81 EgretJson,
83 Psse { rev: u32 },
87 PowerWorld,
89 PandapowerJson,
91 Matpower,
93 Pslf,
95 Goc3Json,
98 SurgeJson,
100 DeepMindOpfDataJson,
103}
104
105impl TargetFormat {
106 #[must_use]
108 pub fn extension(self) -> &'static str {
109 match self {
110 TargetFormat::PowerModelsJson
111 | TargetFormat::EgretJson
112 | TargetFormat::PandapowerJson
113 | TargetFormat::Goc3Json
114 | TargetFormat::SurgeJson
115 | TargetFormat::DeepMindOpfDataJson => "json",
116 TargetFormat::Psse { .. } => "raw",
117 TargetFormat::PowerWorld => "aux",
118 TargetFormat::Matpower => "m",
119 TargetFormat::Pslf => "epc",
120 }
121 }
122
123 #[must_use]
125 pub fn label(self) -> &'static str {
126 match self {
127 TargetFormat::PowerModelsJson => "PowerModels JSON",
128 TargetFormat::EgretJson => "egret JSON",
129 TargetFormat::Psse { .. } => "PSS/E .raw",
130 TargetFormat::PowerWorld => "PowerWorld .aux",
131 TargetFormat::PandapowerJson => "pandapower JSON",
132 TargetFormat::Matpower => "MATPOWER .m",
133 TargetFormat::Pslf => "PSLF .epc",
134 TargetFormat::Goc3Json => "GO Challenge 3 JSON",
135 TargetFormat::SurgeJson => "Surge JSON",
136 TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
137 }
138 }
139
140 #[must_use]
142 pub fn token(self) -> &'static str {
143 match self {
144 TargetFormat::PowerModelsJson => "powermodels-json",
145 TargetFormat::EgretJson => "egret-json",
146 TargetFormat::Psse { rev: 34 } => "psse34",
147 TargetFormat::Psse { rev: 35 } => "psse35",
148 TargetFormat::Psse { .. } => "psse",
149 TargetFormat::PowerWorld => "powerworld",
150 TargetFormat::PandapowerJson => "pandapower-json",
151 TargetFormat::Matpower => "matpower",
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]
258pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
259 Some(match routing::transmission_format_from_name(name)? {
260 TransmissionFormat::Matpower => TargetFormat::Matpower,
261 TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
262 TransmissionFormat::EgretJson => TargetFormat::EgretJson,
263 TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
264 TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
265 TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
266 TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
267 TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
268 TransmissionFormat::Pslf => TargetFormat::Pslf,
269 TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
270 TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
271 TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
272 TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
273 return None;
274 }
275 })
276}
277
278#[derive(Debug, Clone, PartialEq)]
282#[non_exhaustive]
283pub enum DisplayData {
284 PowerWorld(PwdDisplay),
286 Geo(crate::geo::GeoLayer),
288}
289
290impl DisplayData {
291 #[must_use]
293 pub fn format(&self) -> DisplayFormat {
294 match self {
295 DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
296 DisplayData::Geo(_) => DisplayFormat::GeoJson,
297 }
298 }
299}
300
301fn display_file_guidance() -> Error {
302 Error::UnknownFormat(
303 "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
304 use parse_display_file(path, None)"
305 .into(),
306 )
307}
308
309pub fn parse_display_bytes(bytes: &[u8], from: &str) -> Result<DisplayData> {
315 let fmt =
316 display_format_from_name(from).ok_or_else(|| Error::UnknownFormat(from.to_string()))?;
317 match fmt {
318 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
319 bytes,
320 )?)),
321 DisplayFormat::GeoJson => Ok(DisplayData::Geo(
324 crate::geo::GeoLayer::parse_bytes(bytes, None)?.layer,
325 )),
326 }
327}
328
329fn describe_extension(extension: Option<&str>) -> String {
332 match extension {
333 Some(ext) => format!("extension `{ext}`"),
334 None => "no extension".to_owned(),
335 }
336}
337
338pub fn parse_display_file(
347 path: impl AsRef<std::path::Path>,
348 from: Option<&str>,
349) -> Result<DisplayData> {
350 let path = path.as_ref();
351 let fmt = match from {
352 Some(f) => {
353 display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
354 }
355 None => match path
356 .extension()
357 .and_then(|e| e.to_str())
358 .map(str::to_ascii_lowercase)
359 .as_deref()
360 {
361 Some("pwd") => DisplayFormat::PowerWorld,
362 Some("geojson") => DisplayFormat::GeoJson,
363 Some("json")
366 if path
367 .file_name()
368 .and_then(|name| name.to_str())
369 .is_some_and(|name| {
370 name.to_ascii_lowercase()
371 .ends_with(crate::geo::GEO_LAYER_EXTENSION)
372 }) =>
373 {
374 DisplayFormat::GeoJson
375 }
376 other => {
377 return Err(Error::UnknownFormat(format!(
378 "cannot infer display format from file with {}; \
379 pass an explicit display format",
380 describe_extension(other)
381 )));
382 }
383 },
384 };
385 let bytes = read_file_bytes(path)?;
386 match fmt {
387 DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
388 &bytes,
389 )?)),
390 DisplayFormat::GeoJson => Ok(DisplayData::Geo(
391 crate::geo::GeoLayer::parse_bytes(&bytes, path.file_name().and_then(|n| n.to_str()))?
392 .layer,
393 )),
394 }
395}
396
397pub(crate) fn named_io_error(path: &std::path::Path, e: &std::io::Error) -> Error {
401 Error::Io(std::io::Error::new(
402 e.kind(),
403 format!("cannot read {}: {e}", path.display()),
404 ))
405}
406
407fn read_file_bytes(path: &std::path::Path) -> Result<Vec<u8>> {
408 std::fs::read(path).map_err(|e| named_io_error(path, &e))
409}
410
411pub fn is_pypsa_csv_name(name: &str) -> bool {
416 matches!(
417 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
418 "pypsacsv" | "pypsa"
419 )
420}
421
422fn is_pslf_name(name: &str) -> bool {
424 matches!(
425 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
426 "pslf" | "epc" | "pslfepc"
427 )
428}
429
430pub fn parse(
459 source: powerio_core::Source,
460) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
461 parse_with_json_class(source, None)
462}
463
464#[doc(hidden)]
479pub fn parse_with_json_class(
480 source: powerio_core::Source,
481 json_class: Option<routing::JsonClass>,
482) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
483 let mut warnings = Diagnostics::new();
484 match parse_to_network(&source, &mut warnings, json_class) {
485 Ok(network) => {
486 let format = network.source_format();
487 let mut module = PioModule::new(network);
488 for buffer in source.acquired_buffers() {
489 let name = std::path::Path::new(buffer.name())
493 .file_name()
494 .and_then(|name| name.to_str())
495 .unwrap_or_else(|| buffer.name());
496 let descriptor = match SourceDescriptor::new(
497 buffer.id().clone(),
498 name,
499 buffer.bytes().len() as u64,
500 ) {
501 Ok(descriptor) => descriptor,
502 Err(error) => return Err(error.with_source(source)),
503 };
504 let descriptor = match powerio_core::FormatId::new(format.name()) {
507 Ok(id) => descriptor.with_format(id),
508 Err(_) => descriptor,
509 };
510 if let Err(error) = module.add_source_descriptor(descriptor) {
511 return Err(error.with_source(source));
512 }
513 }
514 let mut module = module.with_source(source);
515 for record in warnings.into_records() {
516 module.add_diagnostic(record)?;
517 }
518 Ok(module)
519 }
520 Err(error) => {
521 let core = powerio_core::Error::new(error.code(), error.to_string());
522 Err(core
523 .with_diagnostics(warnings.into_records())
524 .with_cause(error)
525 .with_source(source))
526 }
527 }
528}
529
530fn parse_to_network(
536 source: &powerio_core::Source,
537 warnings: &mut Diagnostics,
538 json_class: Option<routing::JsonClass>,
539) -> Result<BalancedNetwork> {
540 let from = source.format().map(powerio_core::FormatId::as_str);
541 let path = std::path::Path::new(source.name());
542 let stem = if source.name().starts_with('<') {
548 None
549 } else if path.extension().is_some() {
550 path.file_stem().and_then(|stem| stem.to_str())
551 } else {
552 Some(source.name())
553 };
554 if source.is_directory() {
558 let marker = powerio_core::ArtifactPath::new("network.csv")
559 .expect("static name is a valid artifact path");
560 if from.is_some_and(is_pypsa_csv_name) || (from.is_none() && source.buffer(&marker).is_ok())
561 {
562 return pypsa::read_pypsa_csv_source(source, warnings);
563 }
564 return Err(Error::UnknownFormat(format!(
567 "{} is a directory, and the only directory case format is a PyPSA CSV \
568 folder (one holding a network.csv); pass a case file",
569 path.display()
570 )));
571 }
572 if from.is_some_and(is_pypsa_csv_name) {
573 return Err(Error::UnknownFormat(
574 "a PyPSA CSV case is a directory holding a network.csv; open the folder as the source"
575 .into(),
576 ));
577 }
578 let ext = path
581 .extension()
582 .and_then(|e| e.to_str())
583 .map(str::to_ascii_lowercase);
584 if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
585 || (from.is_none() && ext.as_deref() == Some("pwb"))
586 {
587 let buffer = primary(source)?;
590 return powerworld::parse_pwb_collecting(buffer.bytes(), stem, warnings);
591 }
592 if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
593 let buffer = primary(source)?;
594 let network = pslf::parse_pslf_source(source_text(&buffer)?, stem, warnings)?;
595 reject_empty_case(&network, "PSLF .epc")?;
596 return Ok(network);
597 }
598 if from
599 .and_then(target_format_from_name)
600 .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
601 && matches!(ext.as_deref(), Some("pt" | "gz"))
602 {
603 return Err(Error::UnknownFormat(
604 "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
605 .into(),
606 ));
607 }
608 if from.is_none() && ext.as_deref() == Some("pwd") {
614 return Err(display_file_guidance());
615 }
616 let fmt_hint = match from {
617 Some(f) => {
618 if display_format_from_name(f).is_some() {
619 return Err(display_file_guidance());
620 }
621 Some(target_format_from_name(f).ok_or_else(|| unknown_source_format(f))?)
622 }
623 None => {
624 match ext.as_deref() {
626 Some("m") => Some(TargetFormat::Matpower),
627 Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
628 Some("aux") => Some(TargetFormat::PowerWorld),
629 Some("json") => None,
630 Some("dss") => return Err(unknown_source_format("dss")),
631 other => {
632 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
637 source_text(&buffer)
638 .is_ok_and(|text| text.trim_start().starts_with(['{', '[']))
639 });
640 if jsonish {
641 None
642 } else {
643 return Err(Error::UnknownFormat(format!(
644 "cannot infer from source name with {}; \
645 declare a source format",
646 describe_extension(other)
647 )));
648 }
649 }
650 }
651 }
652 };
653 let buffer = primary(source)?;
657 let text = source_text(&buffer)?;
658 let fmt = match fmt_hint {
659 Some(fmt) => fmt,
660 None => match json_class.unwrap_or_else(|| routing::classify_json_text(text)) {
666 JsonClass::ModelJson => return BalancedNetwork::from_json(text),
670 class => json_target_from_class(class)?,
671 },
672 };
673 read_source(text, fmt, stem, warnings)
674}
675
676fn primary(source: &powerio_core::Source) -> Result<powerio_core::SourceBuffer> {
678 source.primary_buffer().map_err(|error| Error::FormatRead {
679 format: "source",
680 message: error.to_string(),
681 })
682}
683
684fn source_text(buffer: &powerio_core::SourceBuffer) -> Result<&str> {
687 std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
688 format: "case text",
689 message: format!("not valid UTF-8: {e}"),
690 })
691}
692
693fn read_source(
698 text: &str,
699 fmt: TargetFormat,
700 name_hint: Option<&str>,
701 warnings: &mut Diagnostics,
702) -> Result<BalancedNetwork> {
703 let net = match fmt {
704 TargetFormat::Matpower => matpower::parse_matpower_source(text, name_hint),
705 TargetFormat::PowerModelsJson => {
706 powermodels::parse_powermodels_json_source(text, name_hint, warnings)
707 }
708 TargetFormat::Psse { .. } => psse::parse_psse_source(text, name_hint, warnings),
709 TargetFormat::PowerWorld => {
710 powerworld::map::parse_powerworld_source(text, name_hint, warnings)
711 }
712 TargetFormat::EgretJson => egret::parse_egret_source(text, name_hint),
713 TargetFormat::PandapowerJson => {
714 pandapower::parse_pandapower_source(text, name_hint, warnings)
715 }
716 TargetFormat::Pslf => pslf::parse_pslf_source(text, name_hint, warnings),
719 TargetFormat::Goc3Json => {
723 goc3::parse_goc3_source(text, name_hint, warnings).map(|(net, _goc3)| net)
724 }
725 TargetFormat::SurgeJson => surge::parse_surge_source(text, name_hint, warnings),
726 TargetFormat::DeepMindOpfDataJson => {
727 opfdata::parse_opfdata_source(text, name_hint, warnings)
728 }
729 }?;
730 reject_empty_case(&net, fmt.label())?;
731 Ok(net)
732}
733
734pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
741 let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
742 located.peek()?;
743 let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
744 Some(crate::geo::GeoMeta {
745 space: if in_bounds {
746 crate::geo::CoordinateSpace::Geographic { crs: None }
747 } else {
748 crate::geo::CoordinateSpace::Unknown
749 },
750 kind: None,
751 })
752}
753
754#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
760pub(crate) fn id_from_f64(
761 value: f64,
762 column: impl std::fmt::Display,
763) -> std::result::Result<usize, String> {
764 if value >= 0.0 && value < i64::MAX as f64 {
767 Ok(value as usize)
768 } else {
769 Err(format!(
771 "`{column}` value {value:?} is outside the id range 0..2^63"
772 ))
773 }
774}
775
776pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
782 if net.buses().is_empty() {
783 return Err(Error::FormatRead {
784 format,
785 message: "case has no buses".into(),
786 });
787 }
788 Ok(())
789}
790
791pub const SOURCE_FORMAT_NAMES: &str = "matpower/m, powermodels-json/powermodels/pm, \
797 egret-json/egret, psse/raw, psse34, psse35, powerworld/aux, \
798 pandapower-json/pandapower/pp, pslf/epc, pypsa-csv/pypsa, pwb, goc3-json/goc3, \
799 surge-json/surge, opfdata-json/opfdata/gridopt";
800
801fn unknown_source_format(name: &str) -> Error {
806 if name.eq_ignore_ascii_case("powerio-json") {
807 return Error::UnknownFormat(
808 "the `powerio-json` token was retired in 0.9.0: model JSON is not a case \
809 format or a conversion target; write it with `to_json` \
810 (`pio_balanced_network_to_json` in C, `json_format model-json` on the MCP \
811 server), store the case as `.pio.json`, and classify a JSON document with \
812 `classify_json_text` (family `model-json`)"
813 .into(),
814 );
815 }
816 if let Some(dist) = routing::distribution_format_from_name(name) {
817 return Error::UnknownFormat(format!(
818 "`{}` is a distribution format, and this parser reads only balanced \
819 transmission formats; parse it through the one module family \
820 (powerio::parse in Rust, pio_parse_file in C, powerio.parse in Python, \
821 parse_file in Julia), which routes distribution formats",
822 dist.name()
823 ));
824 }
825 Error::UnknownFormat(format!("{name}; accepted names: {SOURCE_FORMAT_NAMES}"))
826}
827
828#[cfg(test)]
832fn sniff_json(text: &str) -> Result<TargetFormat> {
833 json_target_from_class(routing::classify_json_text(text))
834}
835
836fn json_target_from_class(class: JsonClass) -> Result<TargetFormat> {
840 match class {
841 JsonClass::Module => Err(Error::UnknownFormat(
842 "JSON is a .pio.json stored module; read it with the module surface \
843 (powerio::parse in Rust, pio_parse_str in C, powerio.parse in \
844 Python, parse_bytes in Julia)"
845 .into(),
846 )),
847 JsonClass::ModelJson => Err(Error::UnknownFormat(
848 "JSON is bare powerio model JSON, which is not a case format; read it with \
849 BalancedNetwork::from_json (pio_balanced_network_from_json in C, \
850 powerio.from_json in Python, from_json in Julia)"
851 .into(),
852 )),
853 JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
854 transmission_json_target(format)
855 }
856 JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
857 Err(Error::UnknownFormat(format!(
858 "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
859 format.name()
860 )))
861 }
862 JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
863 "ambiguous JSON markers; pass an explicit source format".into(),
864 )),
865 JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
866 "cannot infer JSON format; pass an explicit source format".into(),
867 )),
868 }
869}
870
871fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
872 match format {
873 TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
874 TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
875 TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
876 TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
877 TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
878 TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
879 other => Err(Error::UnknownFormat(format!(
880 "JSON classifier returned non-JSON transmission format `{}`",
881 other.name()
882 ))),
883 }
884}
885
886#[derive(Debug, Clone)]
897#[non_exhaustive]
898pub struct Conversion {
899 pub text: String,
900 pub diagnostics: Vec<Diagnostic>,
903}
904
905impl Conversion {
906 pub(crate) fn new(text: String, diagnostics: Diagnostics) -> Self {
907 Self {
908 text,
909 diagnostics: diagnostics.into_records(),
910 }
911 }
912
913 pub(crate) fn faithful(text: String) -> Self {
915 Self::new(text, Diagnostics::new())
916 }
917
918 #[must_use]
921 pub fn rendered_diagnostics(&self) -> Vec<String> {
922 crate::diagnostics::render_diagnostics(&self.diagnostics)
923 }
924
925 pub(crate) fn push(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
927 self.diagnostics.push(Diagnostic::of(info, message));
928 }
929
930 pub(crate) fn prepend(&mut self, read: Vec<Diagnostic>) {
932 let mut records = read;
933 records.append(&mut self.diagnostics);
934 self.diagnostics = records;
935 }
936}
937
938#[derive(Debug, Clone, Default)]
944pub struct WriteOptions {
945 pub missing_gen_cost: MissingGenCostPolicy,
946 pub gen_cost_patches: Vec<GenCostPatch>,
947}
948
949impl WriteOptions {
950 #[must_use]
951 pub fn is_default(&self) -> bool {
952 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
953 }
954}
955
956pub fn write_as(
964 module: &PioModule<BalancedNetwork>,
965 format: TargetFormat,
966) -> std::result::Result<Conversion, powerio_core::Error> {
967 if let Some(text) = echo_text(module, format) {
968 return Ok(Conversion::faithful(text));
969 }
970 let mut conv = write_conversion(module.value(), format).map_err(core_error)?;
971 warn_psse_downgrade(module, format, &mut conv);
972 Ok(conv)
973}
974
975pub(crate) fn core_error(error: Error) -> powerio_core::Error {
977 let message = error.to_string();
978 powerio_core::Error::new(error.code(), message).with_cause(error)
979}
980
981fn echo_text(module: &PioModule<BalancedNetwork>, target: TargetFormat) -> Option<String> {
985 let source = module.source()?;
986 let buffer = source.primary_buffer().ok()?;
987 if !same_format(target, module.value().source_format()) {
988 return None;
989 }
990 let text = std::str::from_utf8(buffer.bytes()).ok()?;
991 if let TargetFormat::Psse { rev } = target
995 && psse::header_rev(text.trim_start_matches('\u{feff}')) != rev
996 {
997 return None;
998 }
999 Some(text.to_owned())
1000}
1001
1002pub fn write_network(
1008 net: &BalancedNetwork,
1009 format: TargetFormat,
1010) -> std::result::Result<Conversion, powerio_core::Error> {
1011 write_conversion(net, format).map_err(core_error)
1012}
1013
1014pub(crate) fn write_conversion(net: &BalancedNetwork, format: TargetFormat) -> Result<Conversion> {
1015 let mut conv = match format {
1016 TargetFormat::PowerModelsJson => write_powermodels_json(net),
1017 TargetFormat::EgretJson => write_egret_json(net),
1018 TargetFormat::Psse { rev } => write_psse_rev(net, rev),
1019 TargetFormat::PowerWorld => write_powerworld(net),
1020 TargetFormat::PandapowerJson => write_pandapower_json(net),
1021 TargetFormat::Matpower => matpower::write_matpower_conversion(net),
1025 TargetFormat::Pslf => write_pslf(net),
1026 TargetFormat::SurgeJson => write_surge_json(net),
1027 TargetFormat::Goc3Json => {
1028 return Err(Error::WriteUnsupported {
1029 format: "goc3-json",
1030 });
1031 }
1032 TargetFormat::DeepMindOpfDataJson => {
1033 return Err(Error::WriteUnsupported {
1034 format: "opfdata-json",
1035 });
1036 }
1037 };
1038 warn_normalized_tap(net, format, &mut conv);
1039 warn_missing_reference(net, format, &mut conv);
1040 warn_dropped_frequency(net, format, &mut conv);
1041 warn_dropped_locations(net, format, &mut conv);
1042 warn_dropped_transformer_charging(net, format, &mut conv);
1043 Ok(conv)
1044}
1045
1046pub fn write(
1058 module: &PioModule<BalancedNetwork>,
1059 format: TargetFormat,
1060 destination: powerio_core::Destination,
1061) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1062 write_with_options(module, format, &WriteOptions::default(), destination)
1063}
1064
1065pub fn write_with_options(
1074 module: &PioModule<BalancedNetwork>,
1075 format: TargetFormat,
1076 options: &WriteOptions,
1077 destination: powerio_core::Destination,
1078) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1079 let conv = write_as_with_options(module, format, options)?;
1080 let artifact = powerio_core::MemoryArtifact::new(
1081 powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1082 conv.text.into_bytes(),
1083 );
1084 destination.__commit_artifacts(false, vec![artifact], conv.diagnostics)
1085}
1086
1087pub fn write_pypsa_csv(
1099 module: &PioModule<BalancedNetwork>,
1100 destination: powerio_core::Destination,
1101) -> std::result::Result<powerio_core::WriteResult, powerio_core::Error> {
1102 let (artifacts, diagnostics) = pypsa::pypsa_csv_artifacts(module.value());
1103 let artifacts = artifacts
1104 .into_iter()
1105 .map(|(name, text)| {
1106 powerio_core::MemoryArtifact::new(
1107 powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
1108 text.into_bytes(),
1109 )
1110 })
1111 .collect();
1112 destination.__commit_artifacts(true, artifacts, diagnostics)
1113}
1114
1115pub fn write_as_with_options(
1120 module: &PioModule<BalancedNetwork>,
1121 format: TargetFormat,
1122 options: &WriteOptions,
1123) -> std::result::Result<Conversion, powerio_core::Error> {
1124 if options.is_default() {
1125 return write_as(module, format);
1126 }
1127 let (working, policy_warnings) =
1128 apply_write_cost_policy(module.value(), options).map_err(core_error)?;
1129 let mut conv = write_conversion(&working, format).map_err(core_error)?;
1130 conv.prepend(policy_warnings);
1131 Ok(conv)
1132}
1133
1134pub(crate) fn apply_write_cost_policy(
1139 net: &BalancedNetwork,
1140 options: &WriteOptions,
1141) -> Result<(BalancedNetwork, Vec<Diagnostic>)> {
1142 let mut working = net.clone();
1143 let report =
1144 working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
1145 let mut policy_warnings = Diagnostics::new();
1146 if report.patched > 0 {
1147 policy_warnings.push(
1148 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1149 format!(
1150 "generator cost patch applied to {} generator(s)",
1151 report.patched
1152 ),
1153 );
1154 }
1155 if report.synthesized > 0 {
1156 policy_warnings.push(
1157 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1158 match options.missing_gen_cost {
1159 MissingGenCostPolicy::Fill {
1160 c2,
1161 c1,
1162 c0,
1163 startup,
1164 shutdown,
1165 } => format!(
1166 "generator cost synthesized for {} generator(s): model 2, ncost 3, \
1167 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
1168 report.synthesized
1169 ),
1170 _ => unreachable!("only Fill synthesizes costs"),
1171 },
1172 );
1173 }
1174 Ok((working, policy_warnings.into_records()))
1175}
1176
1177pub(super) fn allocate_circuit_id<K: Ord + Clone>(
1183 preferred: Option<&str>,
1184 key: K,
1185 used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
1186) -> String {
1187 let taken = used.entry(key).or_default();
1188 if let Some(id) = preferred {
1189 if taken.insert(id.to_owned()) {
1190 return id.to_owned();
1191 }
1192 }
1193 let mut n = 1u32;
1194 loop {
1195 let candidate = n.to_string();
1196 if taken.insert(candidate.clone()) {
1197 return candidate;
1198 }
1199 n += 1;
1200 }
1201}
1202
1203fn warn_psse_downgrade(
1211 module: &PioModule<BalancedNetwork>,
1212 format: TargetFormat,
1213 conv: &mut Conversion,
1214) {
1215 let source_text = module
1216 .source()
1217 .and_then(|source| source.primary_buffer().ok())
1218 .and_then(|buffer| String::from_utf8(buffer.content_bytes().to_vec()).ok());
1219 if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) = (
1220 format,
1221 module.value().source_format(),
1222 source_text.as_deref(),
1223 ) {
1224 let src_rev = psse::header_rev(src);
1225 if src_rev > rev {
1226 conv.push(
1227 &codes::EMIT_PSSE_DOWNGRADED,
1228 format!(
1229 "PSS/E source is revision {src_rev} but the write target is revision {rev}; \
1230 the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
1231 ),
1232 );
1233 }
1234 }
1235}
1236
1237fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1242 let carries_frequency = matches!(
1243 format,
1244 TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
1245 );
1246 if carries_frequency {
1247 return;
1248 }
1249 if (net.base_frequency() - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1250 conv.push(
1251 &format.emit_family().field_dropped,
1252 format!(
1253 "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1254 net.base_frequency(),
1255 format.label(),
1256 crate::network::DEFAULT_BASE_FREQUENCY
1257 ),
1258 );
1259 }
1260}
1261
1262fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1269 let carries_locations = matches!(
1270 format,
1271 TargetFormat::PowerWorld | TargetFormat::PandapowerJson
1272 );
1273 if carries_locations {
1274 return;
1275 }
1276 let n = net.buses().iter().filter(|b| b.location.is_some()).count();
1277 let routed = net.branches().iter().filter(|b| b.route.is_some()).count();
1278 if n > 0 || routed > 0 {
1279 conv.push(
1280 &format.emit_family().field_dropped,
1281 format!(
1282 "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1283 coordinate field (write a .geo.json sidecar to keep them)",
1284 format.label()
1285 ),
1286 );
1287 }
1288}
1289
1290fn warn_dropped_transformer_charging(
1296 net: &BalancedNetwork,
1297 format: TargetFormat,
1298 conv: &mut Conversion,
1299) {
1300 if !matches!(format, TargetFormat::Pslf) {
1301 return;
1302 }
1303 let n = net
1304 .branches()
1305 .iter()
1306 .filter(|b| b.is_transformer() && b.total_charging_b() != 0.0)
1307 .count();
1308 if n > 0 {
1309 conv.push(
1310 &codes::EMIT_PSLF.field_dropped,
1311 format!(
1312 "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1313 record cannot represent; the charging was dropped"
1314 ),
1315 );
1316 }
1317}
1318
1319pub(super) fn branch_rating_set_drop_warning(
1320 target: &str,
1321 branch_index: usize,
1322 branch: &Branch,
1323 rating: &BranchRatingSet,
1324) -> String {
1325 format!(
1326 "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1327 branch_index + 1,
1328 branch.from,
1329 branch.to,
1330 rating.name,
1331 rating.rate_mva,
1332 target
1333 )
1334}
1335
1336pub(super) fn warn_dropped_extras(
1343 family: &'static EmitFamily,
1344 target: &str,
1345 net: &BalancedNetwork,
1346 consumed: impl Fn(&str) -> bool,
1347 warnings: &mut Diagnostics,
1348) {
1349 let carries = |extras: &crate::network::Extras| extras.keys().any(|k| !consumed(k));
1350 let dropped = net.buses().iter().filter(|e| carries(&e.extras)).count()
1351 + net.branches().iter().filter(|e| carries(&e.extras)).count()
1352 + net.loads().iter().filter(|e| carries(&e.extras)).count()
1353 + net.shunts().iter().filter(|e| carries(&e.extras)).count()
1354 + net.switches().iter().filter(|e| carries(&e.extras)).count()
1355 + net.storage().iter().filter(|e| carries(&e.extras)).count()
1356 + net.hvdc().iter().filter(|e| carries(&e.extras)).count()
1357 + net
1358 .transformers_3w()
1359 .iter()
1360 .filter(|e| carries(&e.extras))
1361 .count();
1362 if dropped > 0 {
1363 warnings.push(
1364 &family.extras_dropped,
1365 format!(
1366 "{dropped} element(s) carry source-format passthrough fields (extras) the {target} \
1367 writer does not replay; dropped"
1368 ),
1369 );
1370 }
1371}
1372
1373pub(super) fn warn_dropped_areas(
1376 family: &'static EmitFamily,
1377 target: &str,
1378 net: &BalancedNetwork,
1379 warnings: &mut Diagnostics,
1380) {
1381 if !net.areas().is_empty() {
1382 warnings.push(
1383 &family.areas_dropped,
1384 format!(
1385 "{} area record(s) dropped: the {target} writer emits no area table",
1386 net.areas().len()
1387 ),
1388 );
1389 }
1390}
1391
1392pub(super) fn warn_extra_branch_rating_sets(
1393 family: &'static EmitFamily,
1394 target: &str,
1395 net: &BalancedNetwork,
1396 warnings: &mut Diagnostics,
1397) {
1398 for (branch_index, branch) in net.branches().iter().enumerate() {
1399 for rating in &branch.rating_sets {
1400 warnings.push(
1401 &family.rating_set_dropped,
1402 branch_rating_set_drop_warning(target, branch_index, branch, rating),
1403 );
1404 }
1405 }
1406}
1407
1408pub fn format_id_for(
1413 token: &str,
1414) -> std::result::Result<powerio_core::FormatId, powerio_core::Error> {
1415 powerio_core::FormatId::new(token.to_ascii_lowercase().replace('_', "-"))
1416}
1417
1418fn with_declared_format(
1420 source: powerio_core::Source,
1421 from: Option<&str>,
1422) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
1423 match from {
1424 None => Ok(source),
1425 Some(token) => Ok(source.with_format(format_id_for(token)?)),
1426 }
1427}
1428
1429pub fn convert_file(
1441 path: impl AsRef<std::path::Path>,
1442 to: TargetFormat,
1443 from: Option<&str>,
1444) -> std::result::Result<Conversion, powerio_core::Error> {
1445 let source = with_declared_format(powerio_core::Source::open(path.as_ref())?, from)?;
1446 convert_source(source, to, &WriteOptions::default())
1447}
1448
1449pub fn convert_file_with_options(
1451 path: impl AsRef<std::path::Path>,
1452 to: TargetFormat,
1453 from: Option<&str>,
1454 options: &WriteOptions,
1455) -> std::result::Result<Conversion, powerio_core::Error> {
1456 let source = with_declared_format(powerio_core::Source::open(path.as_ref())?, from)?;
1457 convert_source(source, to, options)
1458}
1459
1460pub fn convert_str(
1470 text: &str,
1471 to: TargetFormat,
1472 from: &str,
1473) -> std::result::Result<Conversion, powerio_core::Error> {
1474 convert_str_with_options(text, to, from, &WriteOptions::default())
1475}
1476
1477pub fn convert_str_with_options(
1479 text: &str,
1480 to: TargetFormat,
1481 from: &str,
1482 options: &WriteOptions,
1483) -> std::result::Result<Conversion, powerio_core::Error> {
1484 let source = with_declared_format(
1485 powerio_core::Source::from_bytes("<memory>", text.as_bytes().to_vec())?,
1486 Some(from),
1487 )?;
1488 convert_source(source, to, options)
1489}
1490
1491fn convert_source(
1492 source: powerio_core::Source,
1493 to: TargetFormat,
1494 options: &WriteOptions,
1495) -> std::result::Result<Conversion, powerio_core::Error> {
1496 let module = parse(source)?;
1497 let echoed = options.is_default() && echo_text(&module, to).is_some();
1498 let mut conv = write_as_with_options(&module, to, options)?;
1499 if !echoed {
1500 conv.prepend(module.diagnostics().to_vec());
1501 }
1502 Ok(conv)
1503}
1504
1505pub fn write_dir(
1516 net: &BalancedNetwork,
1517 to: &str,
1518 out_dir: impl AsRef<std::path::Path>,
1519) -> std::result::Result<Vec<Diagnostic>, powerio_core::Error> {
1520 if is_pypsa_csv_name(to) {
1521 return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.diagnostics);
1522 }
1523 Err(core_error(unknown_directory_format(to)))
1524}
1525
1526fn unknown_directory_format(to: &str) -> Error {
1527 Error::UnknownFormat(format!(
1528 "{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
1529 text formats serialize through write_as / to_format"
1530 ))
1531}
1532
1533pub fn write_dir_with_options(
1540 net: &BalancedNetwork,
1541 to: &str,
1542 out_dir: impl AsRef<std::path::Path>,
1543 options: &WriteOptions,
1544) -> std::result::Result<Vec<Diagnostic>, powerio_core::Error> {
1545 if !is_pypsa_csv_name(to) {
1548 return Err(core_error(unknown_directory_format(to)));
1549 }
1550 if options.is_default() {
1551 return write_dir(net, to, out_dir);
1552 }
1553 let (working, mut diagnostics) = apply_write_cost_policy(net, options).map_err(core_error)?;
1554 diagnostics.extend(write_dir(&working, to, out_dir)?);
1555 Ok(diagnostics)
1556}
1557
1558fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1564 let needs_ref = matches!(
1565 format,
1566 TargetFormat::Matpower
1567 | TargetFormat::Psse { .. }
1568 | TargetFormat::PowerModelsJson
1569 | TargetFormat::PandapowerJson
1570 | TargetFormat::Pslf
1571 | TargetFormat::SurgeJson
1572 );
1573 if needs_ref {
1574 if let Some(message) = missing_reference_warning(net) {
1575 conv.push(&format.emit_family().reference_missing, message);
1576 }
1577 }
1578}
1579
1580pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
1584 (!net.buses().iter().any(|b| b.kind == BusType::Ref)).then(|| {
1585 "no reference (slack) bus in the source network; power flow tools \
1586 reject such cases; to_normalized synthesizes a slack at the \
1587 largest pmax in service generator bus"
1588 .to_string()
1589 })
1590}
1591
1592#[allow(clippy::float_cmp)]
1604fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut Conversion) {
1605 if matches!(format, TargetFormat::Matpower) {
1606 return;
1607 }
1608 if let Some(message) = normalized_tap_warning(net) {
1609 conv.push(&format.emit_family().element_relabeled, message);
1610 }
1611}
1612
1613#[allow(clippy::float_cmp)]
1617pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
1618 if !net.is_normalized() {
1619 return None;
1620 }
1621 let ambiguous = net
1625 .branches()
1626 .iter()
1627 .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1628 .count();
1629 (ambiguous > 0).then(|| {
1630 format!(
1631 "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1632 shift, so the line/transformer label is not preserved (the power flow \
1633 is identical)"
1634 )
1635 })
1636}
1637
1638fn nonzero_differs(value: f64, reference: f64) -> bool {
1642 value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1643}
1644
1645pub(crate) fn set_bus_kind(
1648 buses: &mut [Bus],
1649 bus_pos: &HashMap<BusId, usize>,
1650 bus: BusId,
1651 kind: BusType,
1652) {
1653 if let Some(&idx) = bus_pos.get(&bus) {
1654 if buses[idx].kind != BusType::Isolated {
1655 buses[idx].kind = kind;
1656 }
1657 }
1658}
1659
1660pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1662 bus_pos
1663 .get(&bus)
1664 .and_then(|&i| buses.get(i))
1665 .map_or(0.0, |b| b.base_kv)
1666}
1667
1668pub(crate) fn sanitize_quoted<'a>(
1686 value: &'a str,
1687 forbidden: &[char],
1688 replacement: char,
1689) -> std::borrow::Cow<'a, str> {
1690 let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
1691 if value.contains(breaks) {
1692 value
1693 .chars()
1694 .map(|c| if breaks(c) { replacement } else { c })
1695 .collect::<String>()
1696 .into()
1697 } else {
1698 std::borrow::Cow::Borrowed(value)
1699 }
1700}
1701
1702pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
1705 if v_kv > 0.0 && base_mva > 0.0 {
1706 v_kv * v_kv / base_mva
1707 } else {
1708 1.0
1709 }
1710}
1711
1712fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
1714 matches!(
1715 (target, source),
1716 (TargetFormat::Matpower, SourceFormat::Matpower)
1717 | (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
1718 | (TargetFormat::EgretJson, SourceFormat::EgretJson)
1719 | (TargetFormat::Psse { .. }, SourceFormat::Psse)
1720 | (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
1721 | (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
1722 | (TargetFormat::Pslf, SourceFormat::Pslf)
1723 | (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
1724 | (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
1725 | (
1726 TargetFormat::DeepMindOpfDataJson,
1727 SourceFormat::DeepMindOpfDataJson,
1728 )
1729 )
1730}
1731
1732pub(crate) fn jnum(x: f64) -> Value {
1734 serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
1735}
1736
1737pub(crate) fn finish(
1741 family: &'static EmitFamily,
1742 root: Map<String, Value>,
1743 mut warnings: Diagnostics,
1744) -> Conversion {
1745 let value = Value::Object(root);
1746 let mut nulls = BTreeSet::new();
1747 collect_null_keys(&value, &mut nulls);
1748 if !nulls.is_empty() {
1749 warnings.push(
1750 &family.not_a_number,
1751 format!(
1752 "non-finite numeric values written as JSON null in field(s): {}",
1753 nulls.into_iter().collect::<Vec<_>>().join(", ")
1754 ),
1755 );
1756 }
1757 let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
1758 Conversion::new(text, warnings)
1759}
1760
1761fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
1763 match value {
1764 Value::Object(map) => {
1765 for (key, val) in map {
1766 if val.is_null() {
1767 out.insert(key.clone());
1768 } else {
1769 collect_null_keys(val, out);
1770 }
1771 }
1772 }
1773 Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
1774 _ => {}
1775 }
1776}
1777
1778#[cfg(test)]
1781pub(crate) mod test_parse {
1782 use super::*;
1783
1784 #[derive(Debug)]
1785 pub(crate) struct TestParsed {
1786 pub network: BalancedNetwork,
1787 pub diagnostics: Vec<Diagnostic>,
1788 }
1789
1790 impl TestParsed {
1791 pub(crate) fn rendered_diagnostics(&self) -> Vec<String> {
1792 crate::diagnostics::render_diagnostics(&self.diagnostics)
1793 }
1794 }
1795
1796 fn declared(
1797 source: powerio_core::Source,
1798 from: Option<&str>,
1799 ) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
1800 match from {
1801 None => Ok(source),
1802 Some(token) => Ok(source.with_format(powerio_core::FormatId::new(
1803 token.to_ascii_lowercase().replace('_', "-"),
1804 )?)),
1805 }
1806 }
1807
1808 pub(crate) fn parse_file(
1809 path: impl AsRef<std::path::Path>,
1810 from: Option<&str>,
1811 ) -> std::result::Result<TestParsed, powerio_core::Error> {
1812 let source = declared(powerio_core::Source::open(path.as_ref())?, from)?;
1813 parse(source).map(|module| TestParsed {
1814 diagnostics: module.diagnostics().to_vec(),
1815 network: module.into_value(),
1816 })
1817 }
1818
1819 pub(crate) fn parse_str(
1820 text: &str,
1821 from: &str,
1822 ) -> std::result::Result<TestParsed, powerio_core::Error> {
1823 let source = declared(
1824 powerio_core::Source::from_bytes("<memory>", text.as_bytes().to_vec())?,
1825 Some(from),
1826 )?;
1827 parse(source).map(|module| TestParsed {
1828 diagnostics: module.diagnostics().to_vec(),
1829 network: module.into_value(),
1830 })
1831 }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::test_parse::{parse_file, parse_str};
1837 use super::*;
1838 use crate::network::SourceFormat;
1839
1840 #[test]
1841 fn sanitize_quoted_always_replaces_line_terminators() {
1842 for forbidden in [&[][..], &['\''][..], &['"'][..]] {
1846 let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
1847 assert!(
1848 !out.contains('\n') && !out.contains('\r'),
1849 "terminator survived with forbidden={forbidden:?}: {out:?}"
1850 );
1851 }
1852 assert!(matches!(
1854 sanitize_quoted("clean name", &['\''], ' '),
1855 std::borrow::Cow::Borrowed(_)
1856 ));
1857 }
1858
1859 #[test]
1860 fn dss_extension_error_names_the_distribution_surface() {
1861 let path = std::env::temp_dir().join(format!(
1862 "powerio-dss-surface-{}-feeder.dss",
1863 std::process::id()
1864 ));
1865 std::fs::write(&path, "New Circuit.feeder\n").unwrap();
1866 let err = parse_file(&path, None).unwrap_err();
1867 let _ = std::fs::remove_file(&path);
1868 assert!(err.to_string().contains("distribution"), "got: {err}");
1869 }
1870
1871 #[test]
1872 fn io_error_names_the_path() {
1873 let path =
1874 std::env::temp_dir().join(format!("powerio-no-such-case-{}.m", std::process::id()));
1875 let err = parse_file(&path, None).unwrap_err();
1876 assert_eq!(err.category(), powerio_core::ErrorCategory::Io);
1877 let msg = err.to_string();
1878 assert!(
1879 msg.contains(&path.display().to_string()),
1880 "the io failure must name the path: {msg}"
1881 );
1882 }
1883
1884 #[test]
1885 fn a_directory_is_refused_as_a_directory() {
1886 let dir = std::env::temp_dir().join(format!("pglib-opf-23.07-{}", std::process::id()));
1889 std::fs::create_dir_all(&dir).unwrap();
1890 let err = parse_file(&dir, None).unwrap_err();
1891 std::fs::remove_dir_all(&dir).unwrap();
1892 let msg = err.to_string();
1893 assert!(msg.contains("is a directory"), "got: {msg}");
1894 assert!(msg.contains(&dir.display().to_string()), "got: {msg}");
1895 assert!(msg.contains("PyPSA CSV folder"), "got: {msg}");
1896 }
1897
1898 #[test]
1899 fn unknown_format_error_lists_the_accepted_names() {
1900 let err = parse_str("anything", "not-a-format").unwrap_err();
1901 let msg = err.to_string();
1902 assert!(msg.contains("not-a-format"), "got: {msg}");
1903 assert!(msg.contains("accepted names:"), "got: {msg}");
1904 assert!(msg.contains(SOURCE_FORMAT_NAMES), "got: {msg}");
1905 }
1906
1907 #[test]
1908 fn the_accepted_name_list_matches_the_matcher() {
1909 use routing::TransmissionFormat as TF;
1910 let mut canonical = Vec::new();
1912 for clause in SOURCE_FORMAT_NAMES.split(", ") {
1913 for (i, alias) in clause.split('/').enumerate() {
1914 let resolved = routing::transmission_format_from_name(alias);
1915 assert!(
1916 resolved.is_some(),
1917 "listed alias `{alias}` does not resolve"
1918 );
1919 if i == 0 {
1920 canonical.push(resolved.unwrap());
1921 }
1922 }
1923 }
1924 for format in [
1927 TF::Matpower,
1928 TF::PowerModelsJson,
1929 TF::EgretJson,
1930 TF::Psse,
1931 TF::Psse34,
1932 TF::Psse35,
1933 TF::PowerWorld,
1934 TF::PandapowerJson,
1935 TF::PypsaCsv,
1936 TF::Pslf,
1937 TF::Pwb,
1938 TF::Goc3Json,
1939 TF::SurgeJson,
1940 TF::DeepMindOpfDataJson,
1941 ] {
1942 assert!(
1943 canonical.contains(&format),
1944 "{} is missing from SOURCE_FORMAT_NAMES",
1945 format.name()
1946 );
1947 }
1948 }
1949
1950 #[test]
1951 fn a_case_with_generators_and_no_cost_data_warns() {
1952 let costless = "\
1953function mpc = nocost
1954mpc.version = '2';
1955mpc.baseMVA = 100;
1956mpc.bus = [
1957\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1958\t2\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1959];
1960mpc.gen = [
1961\t1\t60\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
1962];
1963mpc.branch = [
1964\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1965];
1966";
1967 let parsed = parse_str(costless, "matpower").unwrap();
1971 assert!(
1972 parsed.rendered_diagnostics().is_empty(),
1973 "{:?}",
1974 parsed.rendered_diagnostics()
1975 );
1976 let normalized = parsed
1977 .network
1978 .to_normalized_with_options(&crate::NormalizeOptions::default())
1979 .unwrap();
1980 let absent: Vec<_> = normalized
1981 .diagnostics
1982 .iter()
1983 .filter(|d| d.code() == "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT")
1984 .collect();
1985 assert_eq!(absent.len(), 1, "{:?}", normalized.warnings);
1986 assert!(absent[0].message().contains("no cost data"), "{absent:?}");
1987 assert!(absent[0].message().contains("1 in-service"), "{absent:?}");
1988
1989 let costed = format!("{costless}mpc.gencost = [\n\t2\t0\t0\t3\t0.01\t40\t0;\n];\n");
1991 let parsed = parse_str(&costed, "matpower").unwrap();
1992 let normalized = parsed
1993 .network
1994 .to_normalized_with_options(&crate::NormalizeOptions::default())
1995 .unwrap();
1996 assert!(
1997 normalized
1998 .diagnostics
1999 .iter()
2000 .all(|d| d.code() != "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT"),
2001 "{:?}",
2002 normalized.warnings
2003 );
2004 }
2005
2006 #[test]
2007 fn distribution_from_token_error_names_the_distribution_surface() {
2008 for token in ["dss", "pmd", "bmopf"] {
2009 let err = parse_str("anything", token).unwrap_err();
2010 assert!(
2011 err.to_string().contains("one module family"),
2012 "{token}: {err}"
2013 );
2014 }
2015 let err = parse_str("anything", "nonesuch").unwrap_err();
2017 assert!(err.to_string().contains("nonesuch"));
2018 }
2019
2020 #[test]
2021 fn byte_order_mark_is_retained_and_echoed() {
2022 let case = "\u{feff}function mpc = t\n\
2027 mpc.version = '2';\n\
2028 mpc.baseMVA = 100;\n\
2029 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2030 mpc.gen = [];\n\
2031 mpc.branch = [];\n";
2032 let source = powerio_core::Source::from_bytes("case.m", case.as_bytes().to_vec()).unwrap();
2033 let module = parse(source.with_format(format_id_for("matpower").unwrap())).unwrap();
2034 assert_eq!(module.value().buses().len(), 1);
2035 assert!(
2036 module.diagnostics().is_empty(),
2037 "{:?}",
2038 module.diagnostics()
2039 );
2040 let echo = write_as(&module, TargetFormat::Matpower).unwrap();
2041 assert_eq!(echo.text, case, "the echo reproduces the mark exactly");
2042 }
2043
2044 #[test]
2045 fn canonical_format_bypasses_same_format_matpower_echo() {
2046 let case = "function mpc = t\n\
2047 % a comment the canonical writer does not keep\n\
2048 mpc.version = '2';\n\
2049 mpc.baseMVA = 100;\n\
2050 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2051 mpc.gen = [];\n\
2052 mpc.branch = [];\n";
2053 let source = powerio_core::Source::from_bytes("case.m", case.as_bytes().to_vec()).unwrap();
2054 let module =
2055 parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap())).unwrap();
2056 assert_eq!(
2057 write_as(&module, TargetFormat::Matpower).unwrap().text,
2058 case
2059 );
2060
2061 let net = module.into_value();
2062 let canonical = net.to_canonical_format(TargetFormat::Matpower).unwrap();
2063 assert_ne!(canonical.text, case);
2064 let reparsed = parse_str(&canonical.text, "matpower").unwrap();
2065 assert_eq!(reparsed.network.buses().len(), 1);
2066 }
2067
2068 #[test]
2069 fn package_json_error_names_the_package_reader() {
2070 let err = sniff_json(r#"{"model_kind":"balanced","model":{}}"#).unwrap_err();
2071 assert!(err.to_string().contains(".pio.json"), "got: {err}");
2072 }
2073
2074 #[test]
2075 fn source_format_strings_round_trip_to_a_target() {
2076 for (sf, want) in [
2082 (SourceFormat::Matpower, TargetFormat::Matpower),
2083 (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
2084 (SourceFormat::EgretJson, TargetFormat::EgretJson),
2085 (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
2086 (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
2087 (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
2088 (SourceFormat::Pslf, TargetFormat::Pslf),
2089 (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
2090 (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
2091 (
2092 SourceFormat::DeepMindOpfDataJson,
2093 TargetFormat::DeepMindOpfDataJson,
2094 ),
2095 ] {
2096 let token = sf.name();
2097 assert_eq!(
2098 target_format_from_name(token),
2099 Some(want),
2100 "source_format {token:?} did not round-trip"
2101 );
2102 let legacy = format!("{sf:?}");
2103 assert_eq!(
2104 target_format_from_name(&legacy),
2105 Some(want),
2106 "legacy spelling {legacy:?} did not round-trip"
2107 );
2108 }
2109 for sf in [
2112 SourceFormat::InMemory,
2113 SourceFormat::Normalized,
2114 SourceFormat::Gridfm,
2115 SourceFormat::PypsaCsv,
2116 SourceFormat::PowerWorldBinary,
2117 ] {
2118 assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
2119 }
2120 }
2121}