1use std::collections::{BTreeSet, HashMap};
37use std::fmt;
38use std::str::FromStr;
39
40use serde_json::{Map, Value};
41
42use powerio_core::PioModule;
43
44use crate::diagnostics::{Diagnostic, DiagnosticInfo, Diagnostics, EmitFamily, codes};
45use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
46use crate::network::{BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat};
47use crate::{Error, Result};
48use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
49
50mod cgmes;
51mod decode;
52mod egret;
53pub(crate) mod goc3;
54mod ieee_cdf;
55mod matpower;
56mod opfdata;
57mod pandapower;
58mod powermodels;
59pub mod powerworld;
60mod pslf;
61pub(crate) mod psse;
62mod pypsa;
63mod rawx;
64pub mod routing;
65mod surge;
66mod ucte;
67mod union_find;
68mod xiidm;
69mod xml;
70
71pub use powerworld::{PwdDisplay, PwdSubstation};
72
73#[doc(hidden)]
74pub use egret::{
75 egret_declares_time_series as __egret_declares_time_series,
76 parse_egret_time_series as __parse_egret_time_series,
77};
78#[doc(hidden)]
79pub use opfdata::{OpfDataSolution, parse_opfdata_json as __parse_opfdata_json};
80#[doc(hidden)]
81pub use pypsa::{
82 PypsaAxis, PypsaCsvSequence, parse_pypsa_csv_time_series as __parse_pypsa_csv_time_series,
83 pypsa_axis as __pypsa_axis,
84};
85
86pub use cgmes::CgmesVersion;
87pub(crate) use egret::write_egret_json;
88pub(crate) use pandapower::write_pandapower_json;
89pub(crate) use powermodels::write_powermodels_json;
90pub(crate) use powerworld::write_powerworld;
91pub(crate) use pslf::write_pslf;
92pub(crate) use psse::write_psse_rev;
93pub(crate) use rawx::write_rawx;
94pub(crate) use surge::write_surge_json;
95pub(crate) use xiidm::{write_jiidm, write_xiidm};
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum TargetFormat {
101 PowerModelsJson,
103 EgretJson,
105 Psse { rev: u32 },
109 PsseRawx,
111 PowerWorld,
113 PandapowerJson,
115 Matpower,
117 Pslf,
119 Goc3Json,
123 SurgeJson,
125 DeepMindOpfDataJson,
128 Xiidm,
130 Jiidm,
132 Cgmes,
134 Ucte,
136}
137
138impl TargetFormat {
139 #[must_use]
141 pub fn extension(self) -> &'static str {
142 match self {
143 TargetFormat::PowerModelsJson
144 | TargetFormat::EgretJson
145 | TargetFormat::PandapowerJson
146 | TargetFormat::Goc3Json
147 | TargetFormat::SurgeJson
148 | TargetFormat::DeepMindOpfDataJson => "json",
149 TargetFormat::Psse { .. } => "raw",
150 TargetFormat::PsseRawx => "rawx",
151 TargetFormat::PowerWorld => "aux",
152 TargetFormat::Matpower => "m",
153 TargetFormat::Pslf => "epc",
154 TargetFormat::Xiidm => "xiidm",
155 TargetFormat::Jiidm => "jiidm",
156 TargetFormat::Cgmes => "xml",
157 TargetFormat::Ucte => "uct",
158 }
159 }
160
161 #[must_use]
163 pub fn label(self) -> &'static str {
164 match self {
165 TargetFormat::PowerModelsJson => "PowerModels JSON",
166 TargetFormat::EgretJson => "egret JSON",
167 TargetFormat::Psse { .. } => "PSS/E .raw",
168 TargetFormat::PsseRawx => "PSS/E RAWX 35",
169 TargetFormat::PowerWorld => "PowerWorld .aux",
170 TargetFormat::PandapowerJson => "pandapower JSON",
171 TargetFormat::Matpower => "MATPOWER .m",
172 TargetFormat::Pslf => "PSLF .epc",
173 TargetFormat::Goc3Json => "GO Challenge 3 JSON",
174 TargetFormat::SurgeJson => "Surge JSON",
175 TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
176 TargetFormat::Xiidm => "XIIDM 1.17 XML",
177 TargetFormat::Jiidm => "JIIDM 1.17 JSON",
178 TargetFormat::Cgmes => "CGMES 3.0 profile set",
179 TargetFormat::Ucte => "UCTE-DEF .uct",
180 }
181 }
182
183 #[must_use]
185 pub fn token(self) -> &'static str {
186 match self {
187 TargetFormat::PowerModelsJson => "powermodels-json",
188 TargetFormat::EgretJson => "egret-json",
189 TargetFormat::Psse { rev: 34 } => "psse34",
190 TargetFormat::Psse { rev: 35 } => "psse35",
191 TargetFormat::Psse { .. } => "psse",
192 TargetFormat::PsseRawx => "psse-rawx",
193 TargetFormat::PowerWorld => "powerworld",
194 TargetFormat::PandapowerJson => "pandapower-json",
195 TargetFormat::Matpower => "matpower",
196 TargetFormat::Pslf => "pslf",
197 TargetFormat::Goc3Json => "goc3-json",
198 TargetFormat::SurgeJson => "surge-json",
199 TargetFormat::DeepMindOpfDataJson => "opfdata-json",
200 TargetFormat::Xiidm => "xiidm",
201 TargetFormat::Jiidm => "jiidm",
202 TargetFormat::Cgmes => "cgmes",
203 TargetFormat::Ucte => "ucte",
204 }
205 }
206}
207
208impl fmt::Display for TargetFormat {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.write_str(self.token())
211 }
212}
213
214impl FromStr for TargetFormat {
215 type Err = Error;
216
217 fn from_str(name: &str) -> Result<Self> {
218 parse_target_format(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
219 }
220}
221
222#[must_use]
238pub fn parse_target_format(name: &str) -> Option<TargetFormat> {
239 if name.eq_ignore_ascii_case("iidm") || name.eq_ignore_ascii_case("rawx") {
242 return None;
243 }
244 Some(match routing::parse_transmission_format(name)? {
245 TransmissionFormat::Matpower => TargetFormat::Matpower,
246 TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
247 TransmissionFormat::EgretJson => TargetFormat::EgretJson,
248 TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
249 TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
250 TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
251 TransmissionFormat::PsseRawx => TargetFormat::PsseRawx,
252 TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
253 TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
254 TransmissionFormat::Pslf => TargetFormat::Pslf,
255 TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
256 TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
257 TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
258 TransmissionFormat::Xiidm => TargetFormat::Xiidm,
259 TransmissionFormat::Jiidm => TargetFormat::Jiidm,
260 TransmissionFormat::Cgmes => TargetFormat::Cgmes,
261 TransmissionFormat::Ucte => TargetFormat::Ucte,
262 TransmissionFormat::PypsaCsv
263 | TransmissionFormat::Pwb
264 | TransmissionFormat::Gridfm
265 | TransmissionFormat::IeeeCdf => {
266 return None;
267 }
268 })
269}
270
271fn parse_source_target_format(name: &str) -> Option<TargetFormat> {
275 match routing::parse_transmission_format(name) {
276 Some(TransmissionFormat::Xiidm) => Some(TargetFormat::Xiidm),
277 Some(TransmissionFormat::PsseRawx) => Some(TargetFormat::PsseRawx),
278 _ => parse_target_format(name),
279 }
280}
281
282fn display_file_guidance() -> Error {
283 Error::UnknownFormat(
284 "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
285 `powerio::parse` reads it as powerio.GeoLayer"
286 .into(),
287 )
288}
289
290fn describe_extension(extension: Option<&str>) -> String {
293 match extension {
294 Some(ext) => format!("extension `{ext}`"),
295 None => "no extension".to_owned(),
296 }
297}
298
299fn is_display_format_name(name: &str) -> bool {
302 matches!(
303 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
304 "pwd" | "powerworldpwd" | "powerworlddisplay" | "geojson" | "geo" | "geolayer"
305 )
306}
307
308pub fn is_pypsa_csv_name(name: &str) -> bool {
313 matches!(
314 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
315 "pypsacsv" | "pypsa"
316 )
317}
318
319fn is_pslf_name(name: &str) -> bool {
321 matches!(
322 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
323 "pslf" | "epc" | "pslfepc"
324 )
325}
326
327fn is_ieee_cdf_name(name: &str) -> bool {
329 routing::parse_transmission_format(name) == Some(TransmissionFormat::IeeeCdf)
330}
331
332pub fn parse(
365 source: powerio_core::Source,
366) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
367 parse_with_json_class(source, None)
368}
369
370#[doc(hidden)]
385pub fn parse_with_json_class(
386 source: powerio_core::Source,
387 json_class: Option<routing::JsonClass>,
388) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
389 let json_class = json_class.or_else(|| {
392 let buffer = source.primary_buffer().ok()?;
393 let text = std::str::from_utf8(buffer.content_bytes()).ok()?;
394 Some(routing::classify_json_text(text))
395 });
396 let is_rawx = source
397 .format()
398 .and_then(|format| parse_target_format(format.as_str()))
399 == Some(TargetFormat::PsseRawx)
400 || std::path::Path::new(source.name())
401 .extension()
402 .and_then(|extension| extension.to_str())
403 .is_some_and(|extension| extension.eq_ignore_ascii_case("rawx"))
404 || matches!(
405 json_class,
406 Some(routing::JsonClass::Case(routing::Detection::Known(
407 routing::SourceFormat::Transmission(TransmissionFormat::PsseRawx)
408 )))
409 );
410 let is_xiidm = source.format().is_some_and(|format| {
411 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Xiidm)
412 }) || std::path::Path::new(source.name())
413 .extension()
414 .and_then(|extension| extension.to_str())
415 .is_some_and(|extension| extension.eq_ignore_ascii_case("xiidm"))
416 || (source.format().is_none()
417 && source
418 .primary_buffer()
419 .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes())));
420 let is_jiidm = source.format().is_some_and(|format| {
421 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Jiidm)
422 }) || std::path::Path::new(source.name())
423 .extension()
424 .and_then(|extension| extension.to_str())
425 .is_some_and(|extension| extension.eq_ignore_ascii_case("jiidm"))
426 || matches!(
427 json_class,
428 Some(routing::JsonClass::Case(routing::Detection::Known(
429 routing::SourceFormat::Transmission(TransmissionFormat::Jiidm)
430 )))
431 );
432 let is_cgmes = source.format().is_some_and(|format| {
433 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Cgmes)
434 }) || cgmes::looks_like_profile_set(&source);
435 let mut warnings = Diagnostics::new();
436 match parse_to_network(&source, &mut warnings, json_class) {
437 Ok(mut network) => {
438 network.assign_missing_component_ids();
439 let source = if is_rawx {
443 source.with_format(
444 powerio_core::FormatId::new("psse-rawx")
445 .expect("the canonical RAWX token is valid"),
446 )
447 } else if is_jiidm {
448 source.with_format(
449 powerio_core::FormatId::new("jiidm")
450 .expect("the canonical JIIDM token is valid"),
451 )
452 } else if is_xiidm {
453 source.with_format(
454 powerio_core::FormatId::new("xiidm")
455 .expect("the canonical XIIDM token is valid"),
456 )
457 } else if is_cgmes {
458 source.with_format(
459 powerio_core::FormatId::new("cgmes")
460 .expect("the canonical CGMES token is valid"),
461 )
462 } else if source.format().is_some() {
463 source
464 } else {
465 match powerio_core::FormatId::new(network.source_format().name()) {
466 Ok(format) => source.with_format(format),
467 Err(_) => source,
468 }
469 };
470 PioModule::parsed(network, source, warnings.into_records())
471 }
472 Err(error) => {
473 let mut core = powerio_core::Error::new(error.code(), error.to_string());
476 if let Some(span) = warnings.record_span() {
477 core = core.with_span(span);
478 }
479 Err(core
480 .with_diagnostics(warnings.into_records())
481 .with_cause(error)
482 .with_source(source))
483 }
484 }
485}
486
487#[allow(clippy::too_many_lines)]
493fn parse_to_network(
494 source: &powerio_core::Source,
495 warnings: &mut Diagnostics,
496 json_class: Option<routing::JsonClass>,
497) -> Result<BalancedNetwork> {
498 let from = source.format().map(powerio_core::FormatId::as_str);
499 let path = std::path::Path::new(source.name());
500 let stem = if source.name().starts_with('<') {
506 None
507 } else if path.extension().is_some() {
508 path.file_stem().and_then(|stem| stem.to_str())
509 } else {
510 Some(source.name())
511 };
512 if source.is_directory() {
516 let marker = powerio_core::ArtifactPath::new("network.csv")
517 .expect("static name is a valid artifact path");
518 if from.is_some_and(is_pypsa_csv_name) || (from.is_none() && source.buffer(&marker).is_ok())
519 {
520 return pypsa::read_pypsa_csv_source(source, warnings);
521 }
522 if from.is_some_and(|format| {
523 routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
524 }) || (from.is_none() && cgmes::looks_like_profile_set(source))
525 {
526 return cgmes::parse_source(source, warnings);
527 }
528 return Err(Error::UnknownFormat(format!(
531 "{} is a directory, and the only directory case format is a PyPSA CSV \
532 folder (one holding a network.csv); pass a case file",
533 path.display()
534 )));
535 }
536 if from.is_some_and(is_pypsa_csv_name) {
537 return Err(Error::UnknownFormat(
538 "a PyPSA CSV case is a directory holding a network.csv; open the folder as the source"
539 .into(),
540 ));
541 }
542 if from.is_some_and(|format| {
543 routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
544 }) || (from.is_none() && cgmes::looks_like_profile_set(source))
545 {
546 return cgmes::parse_source(source, warnings);
547 }
548 let ext = path
551 .extension()
552 .and_then(|e| e.to_str())
553 .map(str::to_ascii_lowercase);
554 let looks_like_xiidm = source
555 .primary_buffer()
556 .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes()));
557 if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
558 || (from.is_none() && ext.as_deref() == Some("pwb"))
559 {
560 let buffer = primary(source)?;
563 return powerworld::parse_pwb_collecting(buffer.bytes(), stem, warnings);
564 }
565 if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
566 let buffer = primary(source)?;
567 let network = pslf::parse_pslf_source(source_text(&buffer)?, stem, warnings)?;
568 reject_empty_case(&network, "PSLF .epc")?;
569 return Ok(network);
570 }
571 if from.is_some_and(is_ieee_cdf_name)
575 || (from.is_none()
576 && matches!(ext.as_deref(), Some("txt" | "cdf"))
577 && source
578 .primary_buffer()
579 .is_ok_and(|buffer| ieee_cdf::looks_like_ieee_cdf(buffer.content_bytes())))
580 {
581 let buffer = primary(source)?;
582 let text = source_text(&buffer)?;
583 let origin = ieee_cdf::TextOrigin::new(
586 buffer.id().clone(),
587 (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
588 );
589 let network = ieee_cdf::parse_ieee_cdf_source(text, stem, Some(origin), warnings)?;
590 reject_empty_case(&network, ieee_cdf::FMT)?;
591 return Ok(network);
592 }
593 if from
594 .and_then(parse_source_target_format)
595 .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
596 && matches!(ext.as_deref(), Some("pt" | "gz"))
597 {
598 return Err(Error::UnknownFormat(
599 "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
600 .into(),
601 ));
602 }
603 if from.is_none() && ext.as_deref() == Some("pwd") {
609 return Err(display_file_guidance());
610 }
611 let fmt_hint = match from {
612 Some(f) => {
613 if is_display_format_name(f) {
614 return Err(display_file_guidance());
615 }
616 Some(parse_source_target_format(f).ok_or_else(|| unknown_source_format(f))?)
617 }
618 None => {
619 match ext.as_deref() {
621 Some("m") => Some(TargetFormat::Matpower),
622 Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
623 Some("rawx") => Some(TargetFormat::PsseRawx),
624 Some("aux") => Some(TargetFormat::PowerWorld),
625 Some("xiidm") => Some(TargetFormat::Xiidm),
626 Some("jiidm") => Some(TargetFormat::Jiidm),
627 Some("xml") if looks_like_xiidm => Some(TargetFormat::Xiidm),
628 Some("xml" | "zip") => Some(TargetFormat::Cgmes),
629 Some("uct") => Some(TargetFormat::Ucte),
630 Some("json") => None,
631 Some("dss") => return Err(unknown_source_format("dss")),
632 other => {
633 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
638 source_text(&buffer)
639 .is_ok_and(|text| text.trim_start().starts_with(['{', '[']))
640 });
641 if jsonish {
642 None
643 } else {
644 return Err(Error::UnknownFormat(format!(
645 "cannot infer from source name with {}; \
646 declare a source format",
647 describe_extension(other)
648 )));
649 }
650 }
651 }
652 }
653 };
654 let buffer = primary(source)?;
658 if fmt_hint == Some(TargetFormat::Xiidm) {
659 let network = xiidm::parse_xiidm_bytes(buffer.content_bytes(), warnings)?;
660 reject_empty_case(&network, TargetFormat::Xiidm.label())?;
661 return Ok(network);
662 }
663 let text = source_text(&buffer)?;
664 warnings.locate_in(
667 buffer.id().clone(),
668 (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
669 );
670 let fmt = match fmt_hint {
671 Some(fmt) => fmt,
672 None => {
678 json_target_from_class(json_class.unwrap_or_else(|| routing::classify_json_text(text)))?
679 }
680 };
681 read_source(text, fmt, stem, warnings)
682}
683
684fn primary(source: &powerio_core::Source) -> Result<powerio_core::SourceBuffer> {
686 source.primary_buffer().map_err(|error| Error::FormatRead {
687 format: "source",
688 message: error.to_string(),
689 })
690}
691
692fn source_text(buffer: &powerio_core::SourceBuffer) -> Result<&str> {
695 std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
696 format: "case text",
697 message: format!("not valid UTF-8: {e}"),
698 })
699}
700
701fn read_source(
708 text: &str,
709 fmt: TargetFormat,
710 name_hint: Option<&str>,
711 warnings: &mut Diagnostics,
712) -> Result<BalancedNetwork> {
713 let net = match fmt {
714 TargetFormat::Matpower => matpower::parse_matpower_source(text, name_hint, warnings),
715 TargetFormat::PowerModelsJson => {
716 powermodels::parse_powermodels_json_source(text, name_hint, warnings)
717 }
718 TargetFormat::Psse { .. } => psse::parse_psse_source(text, name_hint, warnings),
719 TargetFormat::PsseRawx => rawx::parse_rawx_source(text, name_hint, warnings),
720 TargetFormat::PowerWorld => {
721 powerworld::map::parse_powerworld_source(text, name_hint, warnings)
722 }
723 TargetFormat::EgretJson => egret::parse_egret_source(text, name_hint),
724 TargetFormat::PandapowerJson => {
725 pandapower::parse_pandapower_source(text, name_hint, warnings)
726 }
727 TargetFormat::Pslf => pslf::parse_pslf_source(text, name_hint, warnings),
730 TargetFormat::Goc3Json => {
731 return Err(Error::UnknownFormat(
732 "goc3-json defines a GO Challenge 3 calculation; use powerio::parse to obtain AcScucInstance or AcScucSolution"
733 .into(),
734 ));
735 }
736 TargetFormat::SurgeJson => surge::parse_surge_source(text, name_hint, warnings),
737 TargetFormat::DeepMindOpfDataJson => {
738 opfdata::parse_opfdata_source(text, name_hint, warnings)
739 }
740 TargetFormat::Xiidm => xiidm::parse_xiidm_source(text, warnings),
741 TargetFormat::Jiidm => xiidm::parse_jiidm_source(text, warnings),
742 TargetFormat::Cgmes => {
743 cgmes::parse_text(name_hint.unwrap_or("profile.xml"), text, warnings)
744 }
745 TargetFormat::Ucte => ucte::parse_ucte_source(text, name_hint, warnings),
746 }?;
747 reject_empty_case(&net, fmt.label())?;
748 Ok(net)
749}
750
751pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
758 let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
759 located.peek()?;
760 let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
761 Some(crate::geo::GeoMeta {
762 space: if in_bounds {
763 crate::geo::CoordinateSpace::Geographic { crs: None }
764 } else {
765 crate::geo::CoordinateSpace::Unknown
766 },
767 kind: None,
768 })
769}
770
771#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
777pub(crate) fn id_from_f64(
778 value: f64,
779 column: impl std::fmt::Display,
780) -> std::result::Result<usize, String> {
781 if value >= 0.0 && value < i64::MAX as f64 {
784 Ok(value as usize)
785 } else {
786 Err(format!(
788 "`{column}` value {value:?} is outside the id range 0..2^63"
789 ))
790 }
791}
792
793pub(crate) const SUBSTITUTE_NOMINAL_KV: f64 = 1.0;
803
804pub(crate) fn states_nominal_voltage(bus: &crate::network::Bus) -> bool {
806 bus.base_kv.is_finite() && bus.base_kv > 0.0
807}
808
809pub(crate) fn unstated_nominal_voltage(network: &BalancedNetwork) -> Option<(usize, String)> {
812 let ids = network
813 .buses()
814 .iter()
815 .filter(|bus| !states_nominal_voltage(bus))
816 .map(|bus| bus.id.to_string())
817 .collect::<Vec<_>>();
818 if ids.is_empty() {
819 return None;
820 }
821 let shown = ids.iter().take(5).map(String::as_str).collect::<Vec<_>>();
822 let ellipsis = if ids.len() > shown.len() { ", ..." } else { "" };
823 Some((ids.len(), format!("{}{ellipsis}", shown.join(", "))))
824}
825
826pub(crate) fn unbounded_reactive_limits(network: &BalancedNetwork) -> usize {
832 let generators = network
833 .generators()
834 .iter()
835 .flat_map(|generator| [generator.qmin, generator.qmax]);
836 let storage = network
837 .storage()
838 .iter()
839 .flat_map(|storage| [storage.qmin, storage.qmax]);
840 let hvdc = network
841 .hvdc()
842 .iter()
843 .flat_map(|line| [line.qminf, line.qmaxf, line.qmint, line.qmaxt]);
844 generators
845 .chain(storage)
846 .chain(hvdc)
847 .filter(|value| value.is_infinite())
848 .count()
849}
850
851pub(crate) fn substitute_unbounded_reactive_limits(network: &mut BalancedNetwork) {
855 fn bound(value: &mut f64) {
856 if value.is_infinite() {
857 *value = value.signum() * f64::MAX;
858 }
859 }
860 for generator in network.generators_mut() {
861 bound(&mut generator.qmin);
862 bound(&mut generator.qmax);
863 }
864 for storage in network.storage_mut() {
865 bound(&mut storage.qmin);
866 bound(&mut storage.qmax);
867 }
868 for line in network.hvdc_mut() {
869 bound(&mut line.qminf);
870 bound(&mut line.qmaxf);
871 bound(&mut line.qmint);
872 bound(&mut line.qmaxt);
873 }
874}
875
876pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
880 let detailed = net.detailed_connectivity();
881 let has_dc_equipment = detailed.as_ref().is_some_and(|detailed| {
882 !detailed.dc_nodes.is_empty()
883 || !detailed.dc_grounds.is_empty()
884 || !detailed.dc_lines.is_empty()
885 || !detailed.dc_switches.is_empty()
886 });
887 let has_empty_xiidm_voltage_level = matches!(
888 net.source_format(),
889 SourceFormat::Xiidm | SourceFormat::Jiidm
890 ) && detailed
891 .as_ref()
892 .is_some_and(|detailed| !detailed.voltage_levels.is_empty());
893 if net.buses().is_empty() && !has_dc_equipment && !has_empty_xiidm_voltage_level {
894 return Err(Error::FormatRead {
895 format,
896 message: "case has no buses or DC equipment".into(),
897 });
898 }
899 Ok(())
900}
901
902pub const SOURCE_FORMAT_NAMES: &str = "matpower/m, powermodels-json/powermodels/pm, \
909 egret-json/egret, psse/raw, psse34, psse35, psse-rawx/rawx, powerworld/aux, \
910 pandapower-json/pandapower/pp, pslf/epc, pypsa-csv/pypsa, pwb, goc3-json/goc3, \
911 surge-json/surge, opfdata-json/opfdata/gridopt, xiidm/iidm, jiidm, cgmes, ucte/uct, \
912 ieee-cdf/cdf";
913
914fn unknown_source_format(name: &str) -> Error {
919 if let Some(dist) = routing::parse_distribution_format(name) {
920 return Error::UnknownFormat(format!(
921 "`{}` is a distribution format, and this parser reads only balanced \
922 transmission formats; parse it through the one module family \
923 (`powerio::parse` in Rust and `parse` in the language bindings), \
924 which routes distribution formats",
925 dist.name()
926 ));
927 }
928 Error::UnknownFormat(format!("{name}; accepted names: {SOURCE_FORMAT_NAMES}"))
929}
930
931fn json_target_from_class(class: JsonClass) -> Result<TargetFormat> {
934 match class {
935 JsonClass::Module => Err(Error::UnknownFormat(
936 "JSON is PowerIO IR; decode it with `deserialize` rather than the \
937 grid exchange format parser"
938 .into(),
939 )),
940 JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
941 transmission_json_target(format)
942 }
943 JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
944 Err(Error::UnknownFormat(format!(
945 "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
946 format.name()
947 )))
948 }
949 JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
950 "ambiguous JSON markers; pass an explicit source format".into(),
951 )),
952 JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
953 "cannot infer JSON format; pass an explicit source format".into(),
954 )),
955 }
956}
957
958fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
959 match format {
960 TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
961 TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
962 TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
963 TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
964 TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
965 TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
966 TransmissionFormat::PsseRawx => Ok(TargetFormat::PsseRawx),
967 TransmissionFormat::Jiidm => Ok(TargetFormat::Jiidm),
968 other => Err(Error::UnknownFormat(format!(
969 "JSON classifier returned non-JSON transmission format `{}`",
970 other.name()
971 ))),
972 }
973}
974
975#[derive(Debug, Clone)]
977pub(crate) struct TextEmission {
978 pub(crate) text: String,
979 pub(crate) diagnostics: Vec<Diagnostic>,
980 pub(crate) fidelity: powerio_core::Fidelity,
981}
982
983impl TextEmission {
984 pub(crate) fn new(text: String, diagnostics: Diagnostics) -> Self {
985 Self {
986 text,
987 diagnostics: diagnostics.into_records(),
988 fidelity: powerio_core::Fidelity::Canonical,
989 }
990 }
991
992 pub(crate) fn faithful(text: String) -> Self {
994 let mut emission = Self::new(text, Diagnostics::new());
995 emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
996 emission
997 }
998
999 #[cfg(test)]
1000 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
1001 crate::diagnostics::render_diagnostics(&self.diagnostics)
1002 }
1003
1004 pub(crate) fn push(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
1006 self.diagnostics.push(Diagnostic::of(info, message));
1007 }
1008
1009 pub(crate) fn prepend(&mut self, read: Vec<Diagnostic>) {
1011 let mut records = read;
1012 records.append(&mut self.diagnostics);
1013 self.diagnostics = records;
1014 }
1015}
1016
1017#[derive(Debug, Clone, Default)]
1022pub struct EmitOptions {
1023 pub missing_gen_cost: MissingGenCostPolicy,
1024 pub gen_cost_patches: Vec<GenCostPatch>,
1025}
1026
1027impl EmitOptions {
1028 #[must_use]
1029 pub fn is_default(&self) -> bool {
1030 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
1031 }
1032}
1033
1034fn emit_text(
1042 module: &PioModule<BalancedNetwork>,
1043 format: TargetFormat,
1044) -> std::result::Result<TextEmission, powerio_core::Error> {
1045 if let Some(text) = echo_text(module, format) {
1046 return Ok(TextEmission::faithful(text));
1047 }
1048 let mut conv = emit_value_text(module.value(), format).map_err(core_error)?;
1049 warn_psse_downgrade(module, format, &mut conv);
1050 Ok(conv)
1051}
1052
1053pub(crate) fn core_error(error: Error) -> powerio_core::Error {
1055 let message = error.to_string();
1056 powerio_core::Error::new(error.code(), message).with_cause(error)
1057}
1058
1059fn echo_text(module: &PioModule<BalancedNetwork>, target: TargetFormat) -> Option<String> {
1063 let source = module.source()?;
1064 let buffer = source.primary_buffer().ok()?;
1065 let source_format = source
1066 .format()
1067 .and_then(|format| parse_target_format(format.as_str()))?;
1068 if !same_target_format(target, source_format) {
1069 return None;
1070 }
1071 let text = std::str::from_utf8(buffer.bytes()).ok()?;
1072 if let TargetFormat::Psse { rev } = target
1076 && (!matches!(rev, 33..=35)
1077 || psse::header_rev(text.trim_start_matches('\u{feff}')).ok()? != rev)
1078 {
1079 return None;
1080 }
1081 Some(text.to_owned())
1082}
1083
1084pub(crate) fn emit_value_text(net: &BalancedNetwork, format: TargetFormat) -> Result<TextEmission> {
1086 let mut conv = match format {
1087 TargetFormat::PowerModelsJson => write_powermodels_json(net),
1088 TargetFormat::EgretJson => write_egret_json(net),
1089 TargetFormat::Psse { rev } => {
1090 if !matches!(rev, 33..=35) {
1091 return Err(Error::Emit {
1092 format: "PSS/E .raw",
1093 message: format!(
1094 "unsupported revision {rev}; emission supports only revisions 33, 34, and 35"
1095 ),
1096 });
1097 }
1098 net.check_base_mva()?;
1099 write_psse_rev(net, rev)
1100 }
1101 TargetFormat::PsseRawx => {
1102 net.check_base_mva()?;
1103 write_rawx(net)?
1104 }
1105 TargetFormat::PowerWorld => write_powerworld(net),
1106 TargetFormat::PandapowerJson => write_pandapower_json(net),
1107 TargetFormat::Matpower => matpower::write_matpower_conversion(net),
1111 TargetFormat::Pslf => write_pslf(net),
1112 TargetFormat::SurgeJson => write_surge_json(net),
1113 TargetFormat::Goc3Json => {
1114 return Err(Error::WriteUnsupported {
1115 format: "goc3-json",
1116 });
1117 }
1118 TargetFormat::DeepMindOpfDataJson => {
1119 return Err(Error::WriteUnsupported {
1120 format: "opfdata-json",
1121 });
1122 }
1123 TargetFormat::Xiidm => write_xiidm(net)?,
1124 TargetFormat::Jiidm => write_jiidm(net)?,
1125 TargetFormat::Cgmes => {
1126 return Err(Error::WriteUnsupported { format: "cgmes" });
1127 }
1128 TargetFormat::Ucte => ucte::write_ucte(net)?,
1129 };
1130 warn_normalized_tap(net, format, &mut conv);
1131 warn_missing_reference(net, format, &mut conv);
1132 warn_dropped_frequency(net, format, &mut conv);
1133 warn_dropped_locations(net, format, &mut conv);
1134 warn_dropped_transformer_charging(net, format, &mut conv);
1135 warn_dropped_exchange_context(net, format, &mut conv);
1136 Ok(conv)
1137}
1138
1139pub fn emit(
1151 module: &PioModule<BalancedNetwork>,
1152 format: TargetFormat,
1153 destination: powerio_core::Destination,
1154) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1155 emit_with_options(module, format, &EmitOptions::default(), destination)
1156}
1157
1158pub fn emit_with_options(
1167 module: &PioModule<BalancedNetwork>,
1168 format: TargetFormat,
1169 options: &EmitOptions,
1170 destination: powerio_core::Destination,
1171) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1172 if format == TargetFormat::Cgmes
1173 && options.is_default()
1174 && let Some(source) = module.source()
1175 && source
1176 .format()
1177 .is_some_and(|value| value.as_str() == "cgmes")
1178 {
1179 if source.is_directory() {
1180 let mut artifacts = Vec::new();
1181 for name in source.entry_names()? {
1182 let buffer = source.buffer(&name)?;
1183 artifacts.push(powerio_core::MemoryArtifact::new(
1184 name,
1185 buffer.bytes().to_vec(),
1186 ));
1187 }
1188 return destination.__commit_artifacts(
1189 true,
1190 powerio_core::Fidelity::ExactSameFormat,
1191 artifacts,
1192 Vec::new(),
1193 );
1194 }
1195 if source.acquired_buffers().len() == 1 {
1196 let buffer = source.primary_buffer()?;
1197 let file_name = std::path::Path::new(buffer.name())
1198 .file_name()
1199 .and_then(|name| name.to_str())
1200 .and_then(|name| powerio_core::ArtifactPath::new(name).ok())
1201 .unwrap_or_else(|| {
1202 powerio_core::ArtifactPath::new("case.zip")
1203 .expect("static name is a valid artifact path")
1204 });
1205 let artifact = powerio_core::MemoryArtifact::new(file_name, buffer.bytes().to_vec());
1206 return destination.__commit_artifacts(
1207 false,
1208 powerio_core::Fidelity::ExactSameFormat,
1209 vec![artifact],
1210 Vec::new(),
1211 );
1212 }
1213 }
1214 if matches!(format, TargetFormat::Xiidm | TargetFormat::Jiidm)
1215 && options.is_default()
1216 && let Some(source) = module.source()
1217 && source
1218 .format()
1219 .and_then(|value| parse_target_format(value.as_str()))
1220 == Some(format)
1221 && source.acquired_buffers().len() == 1
1222 {
1223 let buffer = source.primary_buffer()?;
1224 let artifact = powerio_core::MemoryArtifact::new(
1225 powerio_core::ArtifactPath::new(format!("case.{}", format.extension()))
1226 .expect("the format extension names a valid artifact path"),
1227 buffer.bytes().to_vec(),
1228 );
1229 return destination.__commit_artifacts(
1230 false,
1231 powerio_core::Fidelity::ExactSameFormat,
1232 vec![artifact],
1233 Vec::new(),
1234 );
1235 }
1236 if format == TargetFormat::Cgmes {
1237 let (working, mut diagnostics) = if options.is_default() {
1238 (module.value().clone(), Vec::new())
1239 } else {
1240 apply_emit_cost_policy(module.value(), options).map_err(core_error)?
1241 };
1242 let (artifacts, format_diagnostics) = cgmes::artifacts(&working).map_err(core_error)?;
1243 diagnostics.extend(format_diagnostics.into_records());
1244 return destination.__commit_artifacts(
1245 true,
1246 powerio_core::Fidelity::Canonical,
1247 artifacts,
1248 diagnostics,
1249 );
1250 }
1251 let conv = emit_text_with_options(module, format, options)?;
1252 let artifact = powerio_core::MemoryArtifact::new(
1253 powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1254 conv.text.into_bytes(),
1255 );
1256 destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
1257}
1258
1259#[doc(hidden)]
1271pub fn __emit_pypsa_csv(
1272 module: &PioModule<BalancedNetwork>,
1273 destination: powerio_core::Destination,
1274) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1275 __emit_pypsa_csv_with_options(module, &EmitOptions::default(), destination)
1276}
1277
1278#[doc(hidden)]
1280pub fn __emit_pypsa_csv_with_options(
1281 module: &PioModule<BalancedNetwork>,
1282 options: &EmitOptions,
1283 destination: powerio_core::Destination,
1284) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1285 let (working, mut diagnostics) = if options.is_default() {
1286 (None, Vec::new())
1287 } else {
1288 let (network, diagnostics) =
1289 apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1290 (Some(network), diagnostics)
1291 };
1292 let (artifacts, format_diagnostics) =
1293 pypsa::pypsa_csv_artifacts(working.as_ref().unwrap_or(module.value()));
1294 diagnostics.extend(format_diagnostics);
1295 let artifacts = artifacts
1296 .into_iter()
1297 .map(|(name, text)| {
1298 powerio_core::MemoryArtifact::new(
1299 powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
1300 text.into_bytes(),
1301 )
1302 })
1303 .collect();
1304 destination.__commit_artifacts(
1305 true,
1306 powerio_core::Fidelity::Canonical,
1307 artifacts,
1308 diagnostics,
1309 )
1310}
1311
1312fn emit_text_with_options(
1317 module: &PioModule<BalancedNetwork>,
1318 format: TargetFormat,
1319 options: &EmitOptions,
1320) -> std::result::Result<TextEmission, powerio_core::Error> {
1321 if options.is_default() {
1322 return emit_text(module, format);
1323 }
1324 let (working, policy_warnings) =
1325 apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1326 let mut conv = emit_value_text(&working, format).map_err(core_error)?;
1327 conv.prepend(policy_warnings);
1328 Ok(conv)
1329}
1330
1331pub(crate) fn apply_emit_cost_policy(
1336 net: &BalancedNetwork,
1337 options: &EmitOptions,
1338) -> Result<(BalancedNetwork, Vec<Diagnostic>)> {
1339 let mut working = net.clone();
1340 let report =
1341 working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
1342 let mut policy_warnings = Diagnostics::new();
1343 if report.patched > 0 {
1344 policy_warnings.push(
1345 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1346 format!(
1347 "generator cost patch applied to {} generator(s)",
1348 report.patched
1349 ),
1350 );
1351 }
1352 if report.synthesized > 0 {
1353 policy_warnings.push(
1354 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1355 match options.missing_gen_cost {
1356 MissingGenCostPolicy::Fill {
1357 c2,
1358 c1,
1359 c0,
1360 startup,
1361 shutdown,
1362 } => format!(
1363 "generator cost synthesized for {} generator(s): model 2, ncost 3, \
1364 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
1365 report.synthesized
1366 ),
1367 _ => unreachable!("only Fill synthesizes costs"),
1368 },
1369 );
1370 }
1371 Ok((working, policy_warnings.into_records()))
1372}
1373
1374pub(crate) fn allocate_circuit_id<K: Ord + Clone>(
1380 preferred: Option<&str>,
1381 key: K,
1382 used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
1383) -> String {
1384 let taken = used.entry(key).or_default();
1385 if let Some(id) = preferred
1386 && taken.insert(id.to_owned())
1387 {
1388 return id.to_owned();
1389 }
1390 let mut n = 1u32;
1391 loop {
1392 let candidate = n.to_string();
1393 if taken.insert(candidate.clone()) {
1394 return candidate;
1395 }
1396 n += 1;
1397 }
1398}
1399
1400fn warn_psse_downgrade(
1408 module: &PioModule<BalancedNetwork>,
1409 format: TargetFormat,
1410 conv: &mut TextEmission,
1411) {
1412 let source_text = module
1413 .source()
1414 .and_then(|source| source.primary_buffer().ok())
1415 .and_then(|buffer| String::from_utf8(buffer.content_bytes().to_vec()).ok());
1416 if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) = (
1417 format,
1418 module.value().source_format(),
1419 source_text.as_deref(),
1420 ) && let Ok(src_rev) = psse::header_rev(src)
1421 && src_rev > rev
1422 {
1423 conv.push(
1424 &codes::EMIT_PSSE_DOWNGRADED,
1425 format!(
1426 "PSS/E source is revision {src_rev} but the emission target is revision {rev}; \
1427 the older layout drops fields the source carried (emit as psse{src_rev} to keep them)"
1428 ),
1429 );
1430 }
1431}
1432
1433fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1438 let carries_frequency = matches!(
1439 format,
1440 TargetFormat::Psse { .. } | TargetFormat::PsseRawx | TargetFormat::PandapowerJson
1441 );
1442 if carries_frequency {
1443 return;
1444 }
1445 if format == TargetFormat::Ucte {
1448 if (net.base_frequency() - 50.0).abs() > 1e-9 {
1449 conv.push(
1450 &format.emit_family().field_dropped,
1451 format!(
1452 "system base frequency {} Hz dropped: UCTE-DEF describes the 50 Hz synchronous area and has no frequency field (reads back as 50 Hz)",
1453 net.base_frequency()
1454 ),
1455 );
1456 }
1457 return;
1458 }
1459 if (net.base_frequency() - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1460 conv.push(
1461 &format.emit_family().field_dropped,
1462 format!(
1463 "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1464 net.base_frequency(),
1465 format.label(),
1466 crate::network::DEFAULT_BASE_FREQUENCY
1467 ),
1468 );
1469 }
1470}
1471
1472fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1479 let carries_locations = matches!(
1480 format,
1481 TargetFormat::PowerWorld | TargetFormat::PandapowerJson
1482 );
1483 if carries_locations {
1484 return;
1485 }
1486 let n = net.buses().iter().filter(|b| b.location.is_some()).count();
1487 let routed = net.branches().iter().filter(|b| b.route.is_some()).count();
1488 if n > 0 || routed > 0 {
1489 conv.push(
1490 &format.emit_family().field_dropped,
1491 format!(
1492 "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1493 coordinate field (emit a .geo.json sidecar to keep them)",
1494 format.label()
1495 ),
1496 );
1497 }
1498}
1499
1500fn warn_dropped_transformer_charging(
1506 net: &BalancedNetwork,
1507 format: TargetFormat,
1508 conv: &mut TextEmission,
1509) {
1510 if !matches!(format, TargetFormat::Pslf) {
1511 return;
1512 }
1513 let n = net
1514 .branches()
1515 .iter()
1516 .filter(|b| b.is_transformer() && b.calc_total_charging_b() != 0.0)
1517 .count();
1518 if n > 0 {
1519 conv.push(
1520 &codes::EMIT_PSLF.field_dropped,
1521 format!(
1522 "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1523 record cannot represent; the charging was dropped"
1524 ),
1525 );
1526 }
1527}
1528
1529fn warn_dropped_exchange_context(
1534 net: &BalancedNetwork,
1535 format: TargetFormat,
1536 conv: &mut TextEmission,
1537) {
1538 if !matches!(
1539 format,
1540 TargetFormat::Matpower
1541 | TargetFormat::PowerModelsJson
1542 | TargetFormat::EgretJson
1543 | TargetFormat::PowerWorld
1544 | TargetFormat::PandapowerJson
1545 | TargetFormat::Pslf
1546 | TargetFormat::SurgeJson
1547 | TargetFormat::Ucte
1548 ) {
1549 return;
1550 }
1551 if let Some(message) = exchange_context_loss(net, format.label()) {
1552 conv.push(&format.emit_family().field_dropped, message);
1553 }
1554}
1555
1556#[allow(clippy::too_many_lines)] pub(super) fn exchange_context_loss(net: &BalancedNetwork, target: &str) -> Option<String> {
1561 let mut dropped = Vec::new();
1562 let metadata = net.case_metadata();
1563 let mut metadata_fields = Vec::new();
1564 if metadata.case_date.is_some() {
1565 metadata_fields.push("case_date");
1566 }
1567 if metadata.forecast_distance.is_some() {
1568 metadata_fields.push("forecast_distance");
1569 }
1570 if metadata.source_model_format.is_some() {
1571 metadata_fields.push("source_model_format");
1572 }
1573 if metadata.minimum_validation_level.is_some() {
1574 metadata_fields.push("minimum_validation_level");
1575 }
1576 if !metadata_fields.is_empty() {
1577 dropped.push(format!(
1578 "case metadata fields `{}`",
1579 metadata_fields.join("`, `")
1580 ));
1581 }
1582
1583 if let Some(detailed) = net.detailed_connectivity().as_deref() {
1584 let records = [
1585 detailed.omitted_fields.len(),
1586 detailed.component_metadata.len(),
1587 detailed.subnetworks.len(),
1588 detailed.substations.len(),
1589 detailed.voltage_levels.len(),
1590 detailed.bus_breaker_buses.len(),
1591 detailed.calculated_buses.len(),
1592 detailed.connectivity_nodes.len(),
1593 detailed.busbar_sections.len(),
1594 detailed.junctions.len(),
1595 detailed.terminals.len(),
1596 detailed.switches.len(),
1597 detailed.internal_connections.len(),
1598 detailed.operational_limit_groups.len(),
1599 detailed.tap_changers.len(),
1600 detailed.equipment_reactive_limits.len(),
1601 detailed.boundary_lines.len(),
1602 detailed.tie_lines.len(),
1603 detailed.dc_converter_units.len(),
1604 detailed.dc_topological_nodes.len(),
1605 detailed.dc_nodes.len(),
1606 detailed.dc_grounds.len(),
1607 detailed.dc_busbars.len(),
1608 detailed.dc_lines.len(),
1609 detailed.dc_series_devices.len(),
1610 detailed.dc_switches.len(),
1611 detailed.voltage_source_converters.len(),
1612 detailed.line_commutated_converters.len(),
1613 ]
1614 .into_iter()
1615 .sum::<usize>();
1616 if records > 0 {
1617 dropped.push(format!(
1618 "the `detailed_connectivity` hierarchy and topology ({records} record(s))"
1619 ));
1620 }
1621 }
1622
1623 let source_uids = net
1624 .buses()
1625 .iter()
1626 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1627 .count()
1628 + net
1629 .loads()
1630 .iter()
1631 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1632 .count()
1633 + net
1634 .shunts()
1635 .iter()
1636 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1637 .count()
1638 + net
1639 .static_var_compensators()
1640 .iter()
1641 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1642 .count()
1643 + net
1644 .branches()
1645 .iter()
1646 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1647 .count()
1648 + net
1649 .switches()
1650 .iter()
1651 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1652 .count()
1653 + net
1654 .generators()
1655 .iter()
1656 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1657 .count()
1658 + net
1659 .storage()
1660 .iter()
1661 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1662 .count()
1663 + net
1664 .hvdc()
1665 .iter()
1666 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1667 .count()
1668 + net
1669 .transformers_3w()
1670 .iter()
1671 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1672 .count()
1673 + net
1674 .areas()
1675 .iter()
1676 .filter(|value| value.uid.is_some())
1677 .count();
1678 if source_uids > 0 {
1679 dropped.push(format!(
1680 "{source_uids} source-assigned stable component identity value(s)"
1681 ));
1682 }
1683 if net.geo().is_some() {
1684 dropped.push("geographic coordinate reference metadata".to_owned());
1685 }
1686 if net.solver().is_some() {
1687 dropped.push("solver and solution-control metadata".to_owned());
1688 }
1689 if dropped.is_empty() {
1690 return None;
1691 }
1692 Some(format!(
1693 "{target} has no complete source-neutral grid exchange context; dropped {}",
1694 dropped.join(", ")
1695 ))
1696}
1697
1698pub(super) fn branch_rating_set_drop_warning(
1699 target: &str,
1700 branch_index: usize,
1701 branch: &Branch,
1702 rating: &BranchRatingSet,
1703) -> String {
1704 format!(
1705 "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1706 branch_index + 1,
1707 branch.from,
1708 branch.to,
1709 rating.name,
1710 rating.rate_mva,
1711 target
1712 )
1713}
1714
1715pub(super) fn warn_dropped_extras(
1722 family: &'static EmitFamily,
1723 target: &str,
1724 net: &BalancedNetwork,
1725 consumed: impl Fn(&str) -> bool,
1726 warnings: &mut Diagnostics,
1727) {
1728 let mut dropped = 0usize;
1729 let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1730 for extras in net
1731 .buses()
1732 .iter()
1733 .map(|e| &e.extras)
1734 .chain(net.branches().iter().map(|e| &e.extras))
1735 .chain(net.loads().iter().map(|e| &e.extras))
1736 .chain(net.shunts().iter().map(|e| &e.extras))
1737 .chain(net.switches().iter().map(|e| &e.extras))
1738 .chain(net.storage().iter().map(|e| &e.extras))
1739 .chain(net.hvdc().iter().map(|e| &e.extras))
1740 .chain(net.transformers_3w().iter().map(|e| &e.extras))
1741 {
1742 let mut carries = false;
1743 for key in extras.keys().filter(|key| !consumed(key)) {
1744 carries = true;
1745 keys.insert(key.as_str());
1746 }
1747 if carries {
1748 dropped += 1;
1749 }
1750 }
1751 if dropped > 0 {
1752 let named = keys
1753 .iter()
1754 .take(EXTRAS_KEYS_NAMED)
1755 .copied()
1756 .collect::<Vec<_>>()
1757 .join("`, `");
1758 let remainder = keys.len().saturating_sub(EXTRAS_KEYS_NAMED);
1759 let remainder = if remainder > 0 {
1760 format!(" and {remainder} more")
1761 } else {
1762 String::new()
1763 };
1764 warnings.push(
1765 &family.extras_dropped,
1766 format!(
1767 "{dropped} element(s) state field(s) `{named}`{remainder} that no {target} record \
1768 holds; dropped"
1769 ),
1770 );
1771 }
1772}
1773
1774const EXTRAS_KEYS_NAMED: usize = 6;
1778
1779pub(super) fn warn_dropped_areas(
1785 family: &'static EmitFamily,
1786 target: &str,
1787 writes_bus_area: bool,
1788 net: &BalancedNetwork,
1789 warnings: &mut Diagnostics,
1790) {
1791 if !writes_bus_area {
1792 if !net.areas().is_empty() {
1793 let count = net.areas().len();
1794 let noun = if count == 1 { "record" } else { "records" };
1795 warnings.push(
1796 &family.areas_dropped,
1797 format!(
1798 "{count} area {noun} dropped: {target} writes neither an area table nor a bus area number"
1799 ),
1800 );
1801 }
1802 return;
1803 }
1804 let mut fields = std::collections::BTreeSet::new();
1805 let mut stated = 0usize;
1806 for area in net.areas() {
1807 let mut states_any = false;
1808 for field in stated_area_fields(area) {
1809 fields.insert(field);
1810 states_any = true;
1811 }
1812 stated += usize::from(states_any);
1813 }
1814 if fields.is_empty() {
1815 return;
1816 }
1817 let total = net.areas().len();
1818 let subject = if total == 1 {
1819 "the area record states".to_owned()
1820 } else {
1821 format!("{stated} of {total} area records state")
1822 };
1823 warnings.push(
1824 &family.areas_dropped,
1825 format!(
1826 "{subject} {}: {target} bus rows carry the area number, but {target} has no record \
1827 for the area's attributes",
1828 fields.into_iter().collect::<Vec<_>>().join(", ")
1829 ),
1830 );
1831}
1832
1833fn stated_area_fields(area: &crate::Area) -> impl Iterator<Item = &'static str> {
1836 [
1837 (area.slack_bus.is_some(), "swing bus"),
1838 (area.net_interchange != 0.0, "scheduled net interchange"),
1839 (area.tolerance != 0.0, "interchange tolerance"),
1840 (area.name.is_some(), "name"),
1841 (area.uid.is_some(), "source identity"),
1842 (area.area_type.is_some(), "classification"),
1843 ]
1844 .into_iter()
1845 .filter_map(|(stated, field)| stated.then_some(field))
1846}
1847
1848pub(super) fn warn_extra_branch_rating_sets(
1849 family: &'static EmitFamily,
1850 target: &str,
1851 net: &BalancedNetwork,
1852 warnings: &mut Diagnostics,
1853) {
1854 for (branch_index, branch) in net.branches().iter().enumerate() {
1855 for rating in &branch.rating_sets {
1856 warnings.push(
1857 &family.rating_set_dropped,
1858 branch_rating_set_drop_warning(target, branch_index, branch, rating),
1859 );
1860 }
1861 }
1862}
1863
1864pub fn parse_format_id(
1869 token: &str,
1870) -> std::result::Result<powerio_core::FormatId, powerio_core::Error> {
1871 powerio_core::FormatId::new(token.to_ascii_lowercase().replace('_', "-"))
1872}
1873
1874fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1880 let needs_ref = matches!(
1881 format,
1882 TargetFormat::Matpower
1883 | TargetFormat::Psse { .. }
1884 | TargetFormat::PsseRawx
1885 | TargetFormat::PowerModelsJson
1886 | TargetFormat::PandapowerJson
1887 | TargetFormat::Pslf
1888 | TargetFormat::SurgeJson
1889 );
1890 if needs_ref && let Some(message) = missing_reference_warning(net) {
1891 conv.push(&format.emit_family().reference_missing, message);
1892 }
1893}
1894
1895pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
1897 (!net.buses().iter().any(|b| b.kind == BusType::Ref)).then(|| {
1898 "no reference (slack) bus in the source network; power flow tools \
1899 reject such cases; to_normalized synthesizes a slack at the \
1900 largest pmax in service generator bus"
1901 .to_string()
1902 })
1903}
1904
1905#[allow(clippy::float_cmp)]
1917fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1918 if matches!(format, TargetFormat::Matpower) {
1919 return;
1920 }
1921 if let Some(message) = normalized_tap_warning(net) {
1922 conv.push(&format.emit_family().element_relabeled, message);
1923 }
1924}
1925
1926#[allow(clippy::float_cmp)]
1930pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
1931 if !net.is_normalized() {
1932 return None;
1933 }
1934 let ambiguous = net
1938 .branches()
1939 .iter()
1940 .filter(|b| b.tap == 1.0 && b.shift == 0.0)
1941 .count();
1942 (ambiguous > 0).then(|| {
1943 format!(
1944 "normalized network: {ambiguous} branch(es) have unit tap and no phase \
1945 shift, so the line/transformer label is not preserved (the power flow \
1946 is identical)"
1947 )
1948 })
1949}
1950
1951pub(super) fn nonzero_differs(value: f64, reference: f64) -> bool {
1955 value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
1956}
1957
1958pub(crate) fn set_bus_kind(
1961 buses: &mut [Bus],
1962 bus_pos: &HashMap<BusId, usize>,
1963 bus: BusId,
1964 kind: BusType,
1965) {
1966 if let Some(&idx) = bus_pos.get(&bus)
1967 && buses[idx].kind != BusType::Isolated
1968 {
1969 buses[idx].kind = kind;
1970 }
1971}
1972
1973pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
1975 bus_pos
1976 .get(&bus)
1977 .and_then(|&i| buses.get(i))
1978 .map_or(0.0, |b| b.base_kv)
1979}
1980
1981pub(crate) fn sanitize_quoted<'a>(
1999 value: &'a str,
2000 forbidden: &[char],
2001 replacement: char,
2002) -> std::borrow::Cow<'a, str> {
2003 let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
2004 if value.contains(breaks) {
2005 value
2006 .chars()
2007 .map(|c| if breaks(c) { replacement } else { c })
2008 .collect::<String>()
2009 .into()
2010 } else {
2011 std::borrow::Cow::Borrowed(value)
2012 }
2013}
2014
2015pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
2018 if v_kv > 0.0 && base_mva > 0.0 {
2019 v_kv * v_kv / base_mva
2020 } else {
2021 1.0
2022 }
2023}
2024
2025fn same_target_format(requested: TargetFormat, source: TargetFormat) -> bool {
2029 requested == source
2030 || matches!(
2031 (requested, source),
2032 (TargetFormat::Psse { .. }, TargetFormat::Psse { .. })
2033 )
2034}
2035
2036pub(crate) fn jnum(x: f64) -> Value {
2038 serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
2039}
2040
2041pub(crate) fn finish(
2045 family: &'static EmitFamily,
2046 root: Map<String, Value>,
2047 mut warnings: Diagnostics,
2048) -> TextEmission {
2049 let value = Value::Object(root);
2050 let mut nulls = BTreeSet::new();
2051 collect_null_keys(&value, &mut nulls);
2052 if !nulls.is_empty() {
2053 warnings.push(
2054 &family.not_a_number,
2055 format!(
2056 "non-finite numeric values written as JSON null in field(s): {}",
2057 nulls.into_iter().collect::<Vec<_>>().join(", ")
2058 ),
2059 );
2060 }
2061 let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
2062 TextEmission::new(text, warnings)
2063}
2064
2065fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
2067 match value {
2068 Value::Object(map) => {
2069 for (key, val) in map {
2070 if val.is_null() {
2071 out.insert(key.clone());
2072 } else {
2073 collect_null_keys(val, out);
2074 }
2075 }
2076 }
2077 Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
2078 _ => {}
2079 }
2080}
2081
2082#[cfg(test)]
2084pub(crate) mod test_parse {
2085 use super::*;
2086
2087 #[derive(Debug)]
2088 pub(crate) struct TestParsed {
2089 pub network: BalancedNetwork,
2090 pub diagnostics: Vec<Diagnostic>,
2091 }
2092
2093 impl TestParsed {
2094 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
2095 crate::diagnostics::render_diagnostics(&self.diagnostics)
2096 }
2097 }
2098
2099 fn declared(
2100 source: powerio_core::Source,
2101 from: Option<&str>,
2102 ) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
2103 match from {
2104 None => Ok(source),
2105 Some(token) => Ok(source.with_format(powerio_core::FormatId::new(
2106 token.to_ascii_lowercase().replace('_', "-"),
2107 )?)),
2108 }
2109 }
2110
2111 pub(crate) fn parse_file(
2112 path: impl AsRef<std::path::Path>,
2113 from: Option<&str>,
2114 ) -> std::result::Result<TestParsed, powerio_core::Error> {
2115 let source = declared(powerio_core::Source::open(path.as_ref())?, from)?;
2116 parse(source).map(|module| TestParsed {
2117 diagnostics: module.diagnostics().to_vec(),
2118 network: module.into_value(),
2119 })
2120 }
2121
2122 pub(crate) fn parse_str(
2123 text: &str,
2124 from: &str,
2125 ) -> std::result::Result<TestParsed, powerio_core::Error> {
2126 let source = declared(
2127 powerio_core::Source::from_memory("<memory>", text.as_bytes().to_vec())?,
2128 Some(from),
2129 )?;
2130 parse(source).map(|module| TestParsed {
2131 diagnostics: module.diagnostics().to_vec(),
2132 network: module.into_value(),
2133 })
2134 }
2135}
2136
2137#[cfg(test)]
2138mod tests {
2139 use super::test_parse::{parse_file, parse_str};
2140 use super::*;
2141 use crate::network::SourceFormat;
2142
2143 #[test]
2144 fn sanitize_quoted_always_replaces_line_terminators() {
2145 for forbidden in [&[][..], &['\''][..], &['"'][..]] {
2149 let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
2150 assert!(
2151 !out.contains('\n') && !out.contains('\r'),
2152 "terminator survived with forbidden={forbidden:?}: {out:?}"
2153 );
2154 }
2155 assert!(matches!(
2157 sanitize_quoted("clean name", &['\''], ' '),
2158 std::borrow::Cow::Borrowed(_)
2159 ));
2160 }
2161
2162 #[test]
2163 fn dss_extension_error_names_the_distribution_surface() {
2164 let path = std::env::temp_dir().join(format!(
2165 "powerio-dss-surface-{}-feeder.dss",
2166 std::process::id()
2167 ));
2168 std::fs::write(&path, "New Circuit.feeder\n").unwrap();
2169 let err = parse_file(&path, None).unwrap_err();
2170 let _ = std::fs::remove_file(&path);
2171 assert!(err.to_string().contains("distribution"), "got: {err}");
2172 }
2173
2174 #[test]
2175 fn io_error_names_the_path() {
2176 let path =
2177 std::env::temp_dir().join(format!("powerio-no-such-case-{}.m", std::process::id()));
2178 let err = parse_file(&path, None).unwrap_err();
2179 assert_eq!(err.category(), powerio_core::ErrorCategory::Io);
2180 let msg = err.to_string();
2181 assert!(
2182 msg.contains(&path.display().to_string()),
2183 "the io failure must name the path: {msg}"
2184 );
2185 }
2186
2187 #[test]
2188 fn a_directory_is_refused_as_a_directory() {
2189 let dir = std::env::temp_dir().join(format!("pglib-opf-23.07-{}", std::process::id()));
2192 std::fs::create_dir_all(&dir).unwrap();
2193 let err = parse_file(&dir, None).unwrap_err();
2194 std::fs::remove_dir_all(&dir).unwrap();
2195 let msg = err.to_string();
2196 assert!(msg.contains("is a directory"), "got: {msg}");
2197 assert!(msg.contains(&dir.display().to_string()), "got: {msg}");
2198 assert!(msg.contains("PyPSA CSV folder"), "got: {msg}");
2199 }
2200
2201 #[test]
2202 fn unknown_format_error_lists_the_accepted_names() {
2203 let err = parse_str("anything", "not-a-format").unwrap_err();
2204 let msg = err.to_string();
2205 assert!(msg.contains("not-a-format"), "got: {msg}");
2206 assert!(msg.contains("accepted names:"), "got: {msg}");
2207 assert!(msg.contains(SOURCE_FORMAT_NAMES), "got: {msg}");
2208 }
2209
2210 #[test]
2211 fn the_accepted_name_list_matches_the_matcher() {
2212 use routing::TransmissionFormat as TF;
2213 let mut canonical = Vec::new();
2215 for clause in SOURCE_FORMAT_NAMES.split(", ") {
2216 for (i, alias) in clause.split('/').enumerate() {
2217 let resolved = routing::parse_transmission_format(alias);
2218 assert!(
2219 resolved.is_some(),
2220 "listed alias `{alias}` does not resolve"
2221 );
2222 if i == 0 {
2223 canonical.push(resolved.unwrap());
2224 }
2225 }
2226 }
2227 for format in [
2230 TF::Matpower,
2231 TF::PowerModelsJson,
2232 TF::EgretJson,
2233 TF::Psse,
2234 TF::Psse34,
2235 TF::Psse35,
2236 TF::PowerWorld,
2237 TF::PandapowerJson,
2238 TF::PypsaCsv,
2239 TF::Pslf,
2240 TF::Pwb,
2241 TF::Goc3Json,
2242 TF::SurgeJson,
2243 TF::DeepMindOpfDataJson,
2244 TF::Ucte,
2245 TF::Xiidm,
2246 TF::Jiidm,
2247 TF::Cgmes,
2248 TF::IeeeCdf,
2249 ] {
2250 assert!(
2251 canonical.contains(&format),
2252 "{} is missing from SOURCE_FORMAT_NAMES",
2253 format.name()
2254 );
2255 }
2256 }
2257
2258 #[test]
2259 fn a_case_with_generators_and_no_cost_data_warns() {
2260 let costless = "\
2261function mpc = nocost
2262mpc.version = '2';
2263mpc.baseMVA = 100;
2264mpc.bus = [
2265\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2266\t2\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2267];
2268mpc.gen = [
2269\t1\t60\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2270];
2271mpc.branch = [
2272\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2273];
2274";
2275 let parsed = parse_str(costless, "matpower").unwrap();
2279 assert!(
2280 parsed.render_diagnostics().is_empty(),
2281 "{:?}",
2282 parsed.render_diagnostics()
2283 );
2284 let normalized = parsed
2285 .network
2286 .to_normalized_with_options(&crate::NormalizeOptions::default())
2287 .unwrap();
2288 let absent: Vec<_> = normalized
2289 .diagnostics
2290 .iter()
2291 .filter(|d| d.code() == "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT")
2292 .collect();
2293 assert_eq!(absent.len(), 1, "{:?}", normalized.warnings);
2294 assert!(absent[0].message().contains("no cost data"), "{absent:?}");
2295 assert!(absent[0].message().contains("1 in-service"), "{absent:?}");
2296
2297 let costed = format!("{costless}mpc.gencost = [\n\t2\t0\t0\t3\t0.01\t40\t0;\n];\n");
2299 let parsed = parse_str(&costed, "matpower").unwrap();
2300 let normalized = parsed
2301 .network
2302 .to_normalized_with_options(&crate::NormalizeOptions::default())
2303 .unwrap();
2304 assert!(
2305 normalized
2306 .diagnostics
2307 .iter()
2308 .all(|d| d.code() != "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT"),
2309 "{:?}",
2310 normalized.warnings
2311 );
2312 }
2313
2314 #[test]
2315 fn distribution_from_token_error_names_the_distribution_surface() {
2316 for token in ["dss", "pmd", "bmopf"] {
2317 let err = parse_str("anything", token).unwrap_err();
2318 assert!(
2319 err.to_string().contains("one module family"),
2320 "{token}: {err}"
2321 );
2322 }
2323 let err = parse_str("anything", "nonesuch").unwrap_err();
2325 assert!(err.to_string().contains("nonesuch"));
2326 }
2327
2328 #[test]
2329 fn byte_order_mark_is_retained_and_echoed() {
2330 let case = "\u{feff}function mpc = t\n\
2335 mpc.version = '2';\n\
2336 mpc.baseMVA = 100;\n\
2337 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2338 mpc.gen = [];\n\
2339 mpc.branch = [];\n";
2340 let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2341 let module = parse(source.with_format(parse_format_id("matpower").unwrap())).unwrap();
2342 assert_eq!(module.value().buses().len(), 1);
2343 assert!(
2344 module.diagnostics().is_empty(),
2345 "{:?}",
2346 module.diagnostics()
2347 );
2348 let echo = emit_text(&module, TargetFormat::Matpower).unwrap();
2349 assert_eq!(echo.text, case, "the echo reproduces the mark exactly");
2350 }
2351
2352 #[test]
2353 fn canonical_format_bypasses_same_format_matpower_echo() {
2354 let case = "function mpc = t\n\
2355 % a comment the canonical writer does not keep\n\
2356 mpc.version = '2';\n\
2357 mpc.baseMVA = 100;\n\
2358 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2359 mpc.gen = [];\n\
2360 mpc.branch = [];\n";
2361 let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2362 let module =
2363 parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap())).unwrap();
2364 assert_eq!(
2365 emit_text(&module, TargetFormat::Matpower).unwrap().text,
2366 case
2367 );
2368
2369 let net = module.into_value();
2370 let canonical = emit_value_text(&net, TargetFormat::Matpower).unwrap();
2371 assert_ne!(canonical.text, case);
2372 let reparsed = parse_str(&canonical.text, "matpower").unwrap();
2373 assert_eq!(reparsed.network.buses().len(), 1);
2374 }
2375
2376 #[test]
2377 fn bus_branch_formats_report_dropped_exchange_context_once() {
2378 let case = "function mpc = t\n\
2379 mpc.version = '2';\n\
2380 mpc.baseMVA = 100;\n\
2381 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2382 mpc.gen = [];\n\
2383 mpc.branch = [];\n";
2384 let mut net = parse_str(case, "matpower").unwrap().network;
2385 *net.detailed_connectivity_mut() =
2386 Some(std::sync::Arc::new(crate::DetailedConnectivity::default()));
2387 assert!(exchange_context_loss(&net, "PowerModels JSON").is_none());
2388
2389 net.case_metadata_mut().case_date = Some("2025-01-02T03:04:05Z".into());
2390 net.buses_mut()[0].uid = Some("source-bus".into());
2391 let mut detailed = crate::DetailedConnectivity::default();
2392 detailed.substations.push(crate::Substation {
2393 component: powerio_core::ComponentId::new("substation", "source-substation").unwrap(),
2394 country: None,
2395 operator: None,
2396 geographical_tags: Vec::new(),
2397 });
2398 *net.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2399
2400 let emitted = emit_value_text(&net, TargetFormat::PowerModelsJson).unwrap();
2401 let context = emitted
2402 .diagnostics
2403 .iter()
2404 .filter(|diagnostic| {
2405 diagnostic.code() == "EMIT.POWERMODELS.FIELD_DROPPED"
2406 && diagnostic.message().contains("grid exchange context")
2407 })
2408 .collect::<Vec<_>>();
2409 assert_eq!(context.len(), 1, "{:?}", emitted.render_diagnostics());
2410 let message = context[0].message();
2411 assert!(message.contains("case_date"), "{message}");
2412 assert!(message.contains("detailed_connectivity"), "{message}");
2413 assert!(message.contains("1 source-assigned"), "{message}");
2414 }
2415
2416 #[test]
2417 fn source_format_strings_round_trip_to_a_target() {
2418 for (sf, want) in [
2422 (SourceFormat::Matpower, TargetFormat::Matpower),
2423 (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
2424 (SourceFormat::EgretJson, TargetFormat::EgretJson),
2425 (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
2426 (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
2427 (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
2428 (SourceFormat::Pslf, TargetFormat::Pslf),
2429 (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
2430 (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
2431 (SourceFormat::Ucte, TargetFormat::Ucte),
2432 (
2433 SourceFormat::DeepMindOpfDataJson,
2434 TargetFormat::DeepMindOpfDataJson,
2435 ),
2436 ] {
2437 let token = sf.name();
2438 assert_eq!(
2439 parse_target_format(token),
2440 Some(want),
2441 "source_format {token:?} did not round-trip"
2442 );
2443 }
2444 for sf in [
2447 SourceFormat::InMemory,
2448 SourceFormat::Normalized,
2449 SourceFormat::Gridfm,
2450 SourceFormat::PypsaCsv,
2451 SourceFormat::PowerWorldBinary,
2452 SourceFormat::IeeeCdf,
2453 ] {
2454 assert_eq!(parse_target_format(sf.name()), None);
2455 }
2456 }
2457}