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::{
47 BalancedNetwork, Branch, BranchRatingSet, Bus, BusId, BusType, SourceFormat, Transformer3W,
48};
49use crate::{Error, Result};
50use routing::{Detection, JsonClass, SourceFormat as DetectedFormat, TransmissionFormat};
51
52mod cgmes;
53mod decode;
54mod egret;
55pub(crate) mod goc3;
56mod ieee_cdf;
57mod json_extension;
58mod matpower;
59mod opfdata;
60mod pandapower;
61mod powermodels;
62pub mod powerworld;
63mod powsybl_psse;
64mod pslf;
65pub(crate) mod psse;
66mod pypsa;
67mod ravens;
68mod rawx;
69pub mod routing;
70mod surge;
71
72mod ucte;
73mod union_find;
74mod xiidm;
75mod xml;
76
77pub use powerworld::{PwdDisplay, PwdSubstation};
78
79#[doc(hidden)]
80pub use egret::{
81 egret_declares_time_series as __egret_declares_time_series,
82 emit_time_series as __emit_egret_time_series,
83 parse_egret_time_series as __parse_egret_time_series,
84};
85#[doc(hidden)]
86pub use opfdata::{OpfDataSolution, parse_opfdata_json as __parse_opfdata_json};
87#[doc(hidden)]
88pub use pypsa::{
89 PypsaAxis, PypsaCsvSequence, parse_pypsa_csv_time_series as __parse_pypsa_csv_time_series,
90 pypsa_axis as __pypsa_axis,
91};
92
93pub use cgmes::CgmesVersion;
94pub(crate) use egret::write_egret_json;
95pub(crate) use pandapower::write_pandapower_json;
96pub(crate) use powermodels::write_powermodels_json;
97pub(crate) use powerworld::write_powerworld;
98pub(crate) use pslf::write_pslf;
99pub(crate) use psse::write_psse_rev;
100pub(crate) use ravens::write_ravens_json;
101pub(crate) use rawx::write_rawx;
102pub(crate) use surge::write_surge_json;
103pub(crate) use xiidm::{write_jiidm, write_xiidm};
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum TargetFormat {
109 PowerModelsJson,
111 EgretJson,
113 Psse { rev: u32 },
117 PsseRawx,
119 PowerWorld,
121 PandapowerJson,
123 Matpower,
125 Pslf,
127 Goc3Json,
131 SurgeJson,
133 DeepMindOpfDataJson,
136 Xiidm,
138 Jiidm,
140 Cgmes,
142 Ucte,
144 RavensJson,
147}
148
149impl TargetFormat {
150 #[must_use]
152 pub fn extension(self) -> &'static str {
153 match self {
154 TargetFormat::PowerModelsJson
155 | TargetFormat::EgretJson
156 | TargetFormat::PandapowerJson
157 | TargetFormat::Goc3Json
158 | TargetFormat::SurgeJson
159 | TargetFormat::RavensJson
160 | TargetFormat::DeepMindOpfDataJson => "json",
161 TargetFormat::Psse { .. } => "raw",
162 TargetFormat::PsseRawx => "rawx",
163 TargetFormat::PowerWorld => "aux",
164 TargetFormat::Matpower => "m",
165 TargetFormat::Pslf => "epc",
166 TargetFormat::Xiidm => "xiidm",
167 TargetFormat::Jiidm => "jiidm",
168 TargetFormat::Cgmes => "xml",
169 TargetFormat::Ucte => "uct",
170 }
171 }
172
173 #[must_use]
175 pub fn label(self) -> &'static str {
176 match self {
177 TargetFormat::PowerModelsJson => "PowerModels JSON",
178 TargetFormat::EgretJson => "egret JSON",
179 TargetFormat::Psse { .. } => "PSS/E .raw",
180 TargetFormat::PsseRawx => "PSS/E RAWX 35",
181 TargetFormat::PowerWorld => "PowerWorld .aux",
182 TargetFormat::PandapowerJson => "pandapower JSON",
183 TargetFormat::Matpower => "MATPOWER .m",
184 TargetFormat::Pslf => "PSLF .epc",
185 TargetFormat::Goc3Json => "GO Challenge 3 JSON",
186 TargetFormat::SurgeJson => "Surge JSON",
187 TargetFormat::RavensJson => "MG-RAVENS JSON",
188 TargetFormat::DeepMindOpfDataJson => "DeepMind OPFData JSON",
189 TargetFormat::Xiidm => "XIIDM 1.17 XML",
190 TargetFormat::Jiidm => "JIIDM 1.17 JSON",
191 TargetFormat::Cgmes => "CGMES 3.0 profile set",
192 TargetFormat::Ucte => "UCTE-DEF .uct",
193 }
194 }
195
196 #[must_use]
198 pub fn token(self) -> &'static str {
199 match self {
200 TargetFormat::PowerModelsJson => "powermodels-json",
201 TargetFormat::EgretJson => "egret-json",
202 TargetFormat::Psse { rev: 34 } => "psse34",
203 TargetFormat::Psse { rev: 35 } => "psse35",
204 TargetFormat::Psse { .. } => "psse",
205 TargetFormat::PsseRawx => "psse-rawx",
206 TargetFormat::PowerWorld => "powerworld",
207 TargetFormat::PandapowerJson => "pandapower-json",
208 TargetFormat::Matpower => "matpower",
209 TargetFormat::Pslf => "pslf",
210 TargetFormat::Goc3Json => "goc3-json",
211 TargetFormat::SurgeJson => "surge-json",
212 TargetFormat::RavensJson => "ravens-json",
213 TargetFormat::DeepMindOpfDataJson => "opfdata-json",
214 TargetFormat::Xiidm => "xiidm",
215 TargetFormat::Jiidm => "jiidm",
216 TargetFormat::Cgmes => "cgmes",
217 TargetFormat::Ucte => "ucte",
218 }
219 }
220}
221
222impl fmt::Display for TargetFormat {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.write_str(self.token())
225 }
226}
227
228impl FromStr for TargetFormat {
229 type Err = Error;
230
231 fn from_str(name: &str) -> Result<Self> {
232 parse_target_format(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
233 }
234}
235
236#[must_use]
252pub fn parse_target_format(name: &str) -> Option<TargetFormat> {
253 if name.eq_ignore_ascii_case("iidm") || name.eq_ignore_ascii_case("rawx") {
256 return None;
257 }
258 Some(match routing::parse_transmission_format(name)? {
259 TransmissionFormat::Matpower => TargetFormat::Matpower,
260 TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
261 TransmissionFormat::EgretJson => TargetFormat::EgretJson,
262 TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
263 TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
264 TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
265 TransmissionFormat::PsseRawx => TargetFormat::PsseRawx,
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::RavensJson => TargetFormat::RavensJson,
272 TransmissionFormat::DeepMindOpfDataJson => TargetFormat::DeepMindOpfDataJson,
273 TransmissionFormat::Xiidm => TargetFormat::Xiidm,
274 TransmissionFormat::Jiidm => TargetFormat::Jiidm,
275 TransmissionFormat::Cgmes => TargetFormat::Cgmes,
276 TransmissionFormat::Ucte => TargetFormat::Ucte,
277 TransmissionFormat::PypsaCsv
278 | TransmissionFormat::SincalBalanced
279 | TransmissionFormat::Pwb
280 | TransmissionFormat::Gridfm
281 | TransmissionFormat::IeeeCdf => {
282 return None;
283 }
284 })
285}
286
287fn parse_source_target_format(name: &str) -> Option<TargetFormat> {
291 match routing::parse_transmission_format(name) {
292 Some(TransmissionFormat::Xiidm) => Some(TargetFormat::Xiidm),
293 Some(TransmissionFormat::PsseRawx) => Some(TargetFormat::PsseRawx),
294 _ => parse_target_format(name),
295 }
296}
297
298fn display_file_guidance() -> Error {
299 Error::UnknownFormat(
300 "a PowerWorld .pwd is display data, not a BalancedNetwork case; \
301 `powerio::parse` reads it as powerio.GeoLayer"
302 .into(),
303 )
304}
305
306fn describe_extension(extension: Option<&str>) -> String {
309 match extension {
310 Some(ext) => format!("extension `{ext}`"),
311 None => "no extension".to_owned(),
312 }
313}
314
315fn is_display_format_name(name: &str) -> bool {
318 matches!(
319 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
320 "pwd" | "powerworldpwd" | "powerworlddisplay" | "geojson" | "geo" | "geolayer"
321 )
322}
323
324pub fn is_pypsa_csv_name(name: &str) -> bool {
329 matches!(
330 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
331 "pypsacsv" | "pypsa"
332 )
333}
334
335fn is_pslf_name(name: &str) -> bool {
337 matches!(
338 name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
339 "pslf" | "epc" | "pslfepc"
340 )
341}
342
343fn is_ieee_cdf_name(name: &str) -> bool {
345 routing::parse_transmission_format(name) == Some(TransmissionFormat::IeeeCdf)
346}
347
348pub fn parse(
381 source: powerio_core::Source,
382) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
383 parse_with_json_class(source, None)
384}
385
386#[doc(hidden)]
401pub fn parse_with_json_class(
402 source: powerio_core::Source,
403 json_class: Option<routing::JsonClass>,
404) -> std::result::Result<PioModule<BalancedNetwork>, powerio_core::Error> {
405 if sincal_source_declared(&source) {
406 return Err(sincal_feature_disabled(&source));
407 }
408 let json_class = json_class.or_else(|| {
411 let buffer = source.primary_buffer().ok()?;
412 let text = std::str::from_utf8(buffer.content_bytes()).ok()?;
413 Some(routing::classify_json_text(text))
414 });
415 let is_rawx = source
416 .format()
417 .and_then(|format| parse_target_format(format.as_str()))
418 == Some(TargetFormat::PsseRawx)
419 || std::path::Path::new(source.name())
420 .extension()
421 .and_then(|extension| extension.to_str())
422 .is_some_and(|extension| extension.eq_ignore_ascii_case("rawx"))
423 || matches!(
424 json_class,
425 Some(routing::JsonClass::Case(routing::Detection::Known(
426 routing::SourceFormat::Transmission(TransmissionFormat::PsseRawx)
427 )))
428 );
429 let is_xiidm = source.format().is_some_and(|format| {
430 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Xiidm)
431 }) || std::path::Path::new(source.name())
432 .extension()
433 .and_then(|extension| extension.to_str())
434 .is_some_and(|extension| extension.eq_ignore_ascii_case("xiidm"))
435 || (source.format().is_none()
436 && source
437 .primary_buffer()
438 .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes())));
439 let is_jiidm = source.format().is_some_and(|format| {
440 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Jiidm)
441 }) || std::path::Path::new(source.name())
442 .extension()
443 .and_then(|extension| extension.to_str())
444 .is_some_and(|extension| extension.eq_ignore_ascii_case("jiidm"))
445 || matches!(
446 json_class,
447 Some(routing::JsonClass::Case(routing::Detection::Known(
448 routing::SourceFormat::Transmission(TransmissionFormat::Jiidm)
449 )))
450 );
451 let is_cgmes = source.format().is_some_and(|format| {
452 routing::parse_transmission_format(format.as_str()) == Some(TransmissionFormat::Cgmes)
453 }) || cgmes::looks_like_profile_set(&source);
454 let mut warnings = Diagnostics::new();
455 match parse_to_network(&source, &mut warnings, json_class) {
456 Ok(mut network) => {
457 network.assign_missing_component_ids();
458 let source = if is_rawx {
462 source.with_format(
463 powerio_core::FormatId::new("psse-rawx")
464 .expect("the canonical RAWX token is valid"),
465 )
466 } else if is_jiidm {
467 source.with_format(
468 powerio_core::FormatId::new("jiidm")
469 .expect("the canonical JIIDM token is valid"),
470 )
471 } else if is_xiidm {
472 source.with_format(
473 powerio_core::FormatId::new("xiidm")
474 .expect("the canonical XIIDM token is valid"),
475 )
476 } else if is_cgmes {
477 source.with_format(
478 powerio_core::FormatId::new("cgmes")
479 .expect("the canonical CGMES token is valid"),
480 )
481 } else if source.format().is_some() {
482 source
483 } else {
484 match powerio_core::FormatId::new(network.source_format().name()) {
485 Ok(format) => source.with_format(format),
486 Err(_) => source,
487 }
488 };
489 PioModule::parsed(network, source, warnings.into_records())
490 }
491 Err(error) => {
492 let mut core = powerio_core::Error::new(error.code(), error.to_string());
495 if let Some(span) = warnings.record_span() {
496 core = core.with_span(span);
497 }
498 Err(core
499 .with_diagnostics(warnings.into_records())
500 .with_cause(error)
501 .with_source(source))
502 }
503 }
504}
505
506#[allow(clippy::too_many_lines)]
512fn parse_to_network(
513 source: &powerio_core::Source,
514 warnings: &mut Diagnostics,
515 json_class: Option<routing::JsonClass>,
516) -> Result<BalancedNetwork> {
517 let from = source.format().map(powerio_core::FormatId::as_str);
518 let path = std::path::Path::new(source.name());
519 let stem = if source.name().starts_with('<') {
525 None
526 } else if path.extension().is_some() {
527 path.file_stem().and_then(|stem| stem.to_str())
528 } else {
529 Some(source.name())
530 };
531 if source.is_directory() {
535 let marker = powerio_core::ArtifactPath::new("network.csv")
536 .expect("static name is a valid artifact path");
537 if from.is_some_and(is_pypsa_csv_name) || (from.is_none() && source.buffer(&marker).is_ok())
538 {
539 return pypsa::read_pypsa_csv_source(source, warnings);
540 }
541 if from.is_some_and(|format| {
542 routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
543 }) || (from.is_none() && cgmes::looks_like_profile_set(source))
544 {
545 return cgmes::parse_source(source, warnings);
546 }
547 return Err(Error::UnknownFormat(format!(
550 "{} is a directory, and the only directory case format is a PyPSA CSV \
551 folder (one holding a network.csv); pass a case file",
552 path.display()
553 )));
554 }
555 if from.is_some_and(is_pypsa_csv_name) {
556 return Err(Error::UnknownFormat(
557 "a PyPSA CSV case is a directory holding a network.csv; open the folder as the source"
558 .into(),
559 ));
560 }
561 if from.is_some_and(|format| {
562 routing::parse_transmission_format(format) == Some(TransmissionFormat::Cgmes)
563 }) || (from.is_none() && cgmes::looks_like_profile_set(source))
564 {
565 return cgmes::parse_source(source, warnings);
566 }
567 let ext = path
570 .extension()
571 .and_then(|e| e.to_str())
572 .map(str::to_ascii_lowercase);
573 let looks_like_xiidm = source
574 .primary_buffer()
575 .is_ok_and(|buffer| xiidm::looks_like_xiidm(buffer.content_bytes()));
576 if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
577 || (from.is_none() && ext.as_deref() == Some("pwb"))
578 {
579 let buffer = primary(source)?;
582 return powerworld::parse_pwb_collecting(buffer.bytes(), stem, warnings);
583 }
584 if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
585 let buffer = primary(source)?;
586 let network = pslf::parse_pslf_source(source_text(&buffer)?, stem, warnings)?;
587 reject_empty_case(&network, "PSLF .epc")?;
588 return Ok(network);
589 }
590 if from.is_some_and(is_ieee_cdf_name)
594 || (from.is_none()
595 && matches!(ext.as_deref(), Some("txt" | "cdf"))
596 && source
597 .primary_buffer()
598 .is_ok_and(|buffer| ieee_cdf::looks_like_ieee_cdf(buffer.content_bytes())))
599 {
600 let buffer = primary(source)?;
601 let text = source_text(&buffer)?;
602 let origin = ieee_cdf::TextOrigin::new(
605 buffer.id().clone(),
606 (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
607 );
608 let network = ieee_cdf::parse_ieee_cdf_source(text, stem, Some(origin), warnings)?;
609 reject_empty_case(&network, ieee_cdf::FMT)?;
610 return Ok(network);
611 }
612 if from
613 .and_then(parse_source_target_format)
614 .is_some_and(|format| format == TargetFormat::DeepMindOpfDataJson)
615 && matches!(ext.as_deref(), Some("pt" | "gz"))
616 {
617 return Err(Error::UnknownFormat(
618 "OPFData .pt tensor caches and .tar.gz archives are not case files; extract and parse an example_N.json source file"
619 .into(),
620 ));
621 }
622 if from.is_none() && ext.as_deref() == Some("pwd") {
628 return Err(display_file_guidance());
629 }
630 let fmt_hint = match from {
631 Some(f) => {
632 if is_display_format_name(f) {
633 return Err(display_file_guidance());
634 }
635 Some(parse_source_target_format(f).ok_or_else(|| unknown_source_format(f))?)
636 }
637 None => {
638 match ext.as_deref() {
640 Some("m") => Some(TargetFormat::Matpower),
641 Some("raw") => Some(TargetFormat::Psse { rev: 33 }),
642 Some("rawx") => Some(TargetFormat::PsseRawx),
643 Some("aux") => Some(TargetFormat::PowerWorld),
644 Some("xiidm") => Some(TargetFormat::Xiidm),
645 Some("jiidm") => Some(TargetFormat::Jiidm),
646 Some("xml") if looks_like_xiidm => Some(TargetFormat::Xiidm),
647 Some("xml" | "zip") => Some(TargetFormat::Cgmes),
648 Some("uct") => Some(TargetFormat::Ucte),
649 Some("json") => None,
650 Some("dss") => return Err(unknown_source_format("dss")),
651 other => {
652 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
657 source_text(&buffer)
658 .is_ok_and(|text| text.trim_start().starts_with(['{', '[']))
659 });
660 if jsonish {
661 None
662 } else {
663 return Err(Error::UnknownFormat(format!(
664 "cannot infer from source name with {}; \
665 declare a source format",
666 describe_extension(other)
667 )));
668 }
669 }
670 }
671 }
672 };
673 let buffer = primary(source)?;
677 if fmt_hint == Some(TargetFormat::Xiidm) {
678 let network = xiidm::parse_xiidm_bytes(buffer.content_bytes(), warnings)?;
679 reject_empty_case(&network, TargetFormat::Xiidm.label())?;
680 return Ok(network);
681 }
682 let text = source_text(&buffer)?;
683 warnings.locate_in(
686 buffer.id().clone(),
687 (buffer.bytes().len() - buffer.content_bytes().len()) as u64,
688 );
689 let fmt = match fmt_hint {
690 Some(fmt) => fmt,
691 None => {
697 json_target_from_class(json_class.unwrap_or_else(|| routing::classify_json_text(text)))?
698 }
699 };
700 if fmt == TargetFormat::Cgmes {
701 cgmes::check_text_budget(source, text)?;
704 }
705 read_source(text, fmt, stem, warnings)
706}
707
708fn primary(source: &powerio_core::Source) -> Result<powerio_core::SourceBuffer> {
710 source.primary_buffer().map_err(|error| Error::FormatRead {
711 format: "source",
712 message: error.to_string(),
713 })
714}
715
716fn source_text(buffer: &powerio_core::SourceBuffer) -> Result<&str> {
719 std::str::from_utf8(buffer.content_bytes()).map_err(|e| Error::FormatRead {
720 format: "case text",
721 message: format!("not valid UTF-8: {e}"),
722 })
723}
724
725fn read_source(
732 text: &str,
733 fmt: TargetFormat,
734 name_hint: Option<&str>,
735 warnings: &mut Diagnostics,
736) -> Result<BalancedNetwork> {
737 let net = match fmt {
738 TargetFormat::Matpower => matpower::parse_matpower_source(text, name_hint, warnings),
739 TargetFormat::PowerModelsJson => {
740 powermodels::parse_powermodels_json_source(text, name_hint, warnings)
741 }
742 TargetFormat::Psse { .. } => psse::parse_psse_source(text, name_hint, warnings),
743 TargetFormat::PsseRawx => rawx::parse_rawx_source(text, name_hint, warnings),
744 TargetFormat::PowerWorld => {
745 powerworld::map::parse_powerworld_source(text, name_hint, warnings)
746 }
747 TargetFormat::EgretJson => egret::parse_egret_source(text, name_hint),
748 TargetFormat::PandapowerJson => {
749 pandapower::parse_pandapower_source(text, name_hint, warnings)
750 }
751 TargetFormat::Pslf => pslf::parse_pslf_source(text, name_hint, warnings),
754 TargetFormat::Goc3Json => {
755 return Err(Error::UnknownFormat(
756 "goc3-json defines a GO Challenge 3 calculation; use powerio::parse to obtain AcScucInstance or AcScucSolution"
757 .into(),
758 ));
759 }
760 TargetFormat::SurgeJson => surge::parse_surge_source(text, name_hint, warnings),
761 TargetFormat::RavensJson => ravens::parse_ravens_source(text, name_hint, warnings),
762 TargetFormat::DeepMindOpfDataJson => {
763 opfdata::parse_opfdata_source(text, name_hint, warnings)
764 }
765 TargetFormat::Xiidm => xiidm::parse_xiidm_source(text, warnings),
766 TargetFormat::Jiidm => xiidm::parse_jiidm_source(text, warnings),
767 TargetFormat::Cgmes => {
768 cgmes::parse_text(name_hint.unwrap_or("profile.xml"), text, warnings)
769 }
770 TargetFormat::Ucte => ucte::parse_ucte_source(text, name_hint, warnings),
771 }?;
772 reject_empty_case(&net, fmt.label())?;
773 Ok(net)
774}
775
776pub(crate) fn geographic_meta(buses: &[Bus]) -> Option<crate::geo::GeoMeta> {
783 let mut located = buses.iter().filter_map(|bus| bus.location).peekable();
784 located.peek()?;
785 let in_bounds = located.all(|location| location.x.abs() <= 180.0 && location.y.abs() <= 90.0);
786 Some(crate::geo::GeoMeta {
787 space: if in_bounds {
788 crate::geo::CoordinateSpace::Geographic { crs: None }
789 } else {
790 crate::geo::CoordinateSpace::Unknown
791 },
792 kind: None,
793 })
794}
795
796#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
802pub(crate) fn id_from_f64(
803 value: f64,
804 column: impl std::fmt::Display,
805) -> std::result::Result<usize, String> {
806 if value >= 0.0 && value < i64::MAX as f64 {
809 Ok(value as usize)
810 } else {
811 Err(format!(
813 "`{column}` value {value:?} is outside the id range 0..2^63"
814 ))
815 }
816}
817
818pub(crate) const SUBSTITUTE_NOMINAL_KV: f64 = 1.0;
828
829pub(crate) fn states_nominal_voltage(bus: &crate::network::Bus) -> bool {
831 bus.base_kv.is_finite() && bus.base_kv > 0.0
832}
833
834pub(crate) fn unstated_nominal_voltage(network: &BalancedNetwork) -> Option<(usize, String)> {
837 let ids = network
838 .buses()
839 .iter()
840 .filter(|bus| !states_nominal_voltage(bus))
841 .map(|bus| bus.id.to_string())
842 .collect::<Vec<_>>();
843 if ids.is_empty() {
844 return None;
845 }
846 let shown = ids.iter().take(5).map(String::as_str).collect::<Vec<_>>();
847 let ellipsis = if ids.len() > shown.len() { ", ..." } else { "" };
848 Some((ids.len(), format!("{}{ellipsis}", shown.join(", "))))
849}
850
851pub(crate) fn unbounded_reactive_limits(network: &BalancedNetwork) -> usize {
857 let generators = network
858 .generators()
859 .iter()
860 .flat_map(|generator| [generator.qmin, generator.qmax]);
861 let storage = network
862 .storage()
863 .iter()
864 .flat_map(|storage| [storage.qmin, storage.qmax]);
865 let hvdc = network
866 .hvdc()
867 .iter()
868 .flat_map(|line| [line.qminf, line.qmaxf, line.qmint, line.qmaxt]);
869 generators
870 .chain(storage)
871 .chain(hvdc)
872 .filter(|value| value.is_infinite())
873 .count()
874}
875
876pub(crate) fn substitute_unbounded_reactive_limits(network: &mut BalancedNetwork) {
880 fn bound(value: &mut f64) {
881 if value.is_infinite() {
882 *value = value.signum() * f64::MAX;
883 }
884 }
885 for generator in network.generators_mut() {
886 bound(&mut generator.qmin);
887 bound(&mut generator.qmax);
888 }
889 for storage in network.storage_mut() {
890 bound(&mut storage.qmin);
891 bound(&mut storage.qmax);
892 }
893 for line in network.hvdc_mut() {
894 bound(&mut line.qminf);
895 bound(&mut line.qmaxf);
896 bound(&mut line.qmint);
897 bound(&mut line.qmaxt);
898 }
899}
900
901pub(crate) fn reject_empty_case(net: &BalancedNetwork, format: &'static str) -> Result<()> {
905 let detailed = net.detailed_connectivity();
906 let has_dc_equipment = detailed.as_ref().is_some_and(|detailed| {
907 !detailed.dc_nodes.is_empty()
908 || !detailed.dc_grounds.is_empty()
909 || !detailed.dc_lines.is_empty()
910 || !detailed.dc_switches.is_empty()
911 });
912 let has_empty_xiidm_voltage_level = matches!(
913 net.source_format(),
914 SourceFormat::Xiidm | SourceFormat::Jiidm
915 ) && detailed
916 .as_ref()
917 .is_some_and(|detailed| !detailed.voltage_levels.is_empty());
918 if net.buses().is_empty() && !has_dc_equipment && !has_empty_xiidm_voltage_level {
919 return Err(Error::FormatRead {
920 format,
921 message: "case has no buses or DC equipment".into(),
922 });
923 }
924 Ok(())
925}
926
927pub const SOURCE_FORMAT_NAMES: &str = "matpower/m, powermodels-json/powermodels/pm, \
934 egret-json/egret, psse/raw, psse34, psse35, psse-rawx/rawx, powerworld/aux, \
935 pandapower-json/pandapower/pp, pslf/epc, pypsa-csv/pypsa, pwb, goc3-json/goc3, \
936 surge-json/surge, ravens-json/ravens/mg-ravens, opfdata-json/opfdata/gridopt, xiidm/iidm, \
937 jiidm, cgmes, ucte/uct, ieee-cdf/cdf";
938
939fn unknown_source_format(name: &str) -> Error {
944 if let Some(dist) = routing::parse_distribution_format(name) {
945 return Error::UnknownFormat(format!(
946 "`{}` is a distribution format, and this parser reads only balanced \
947 transmission formats; parse it through the one module family \
948 (`powerio::parse` in Rust and `parse` in the language bindings), \
949 which routes distribution formats",
950 dist.name()
951 ));
952 }
953 Error::UnknownFormat(format!("{name}; accepted names: {SOURCE_FORMAT_NAMES}"))
954}
955
956fn json_target_from_class(class: JsonClass) -> Result<TargetFormat> {
959 match class {
960 JsonClass::Module => Err(Error::UnknownFormat(
961 "JSON is PowerIO IR; decode it with `deserialize` rather than the \
962 grid exchange format parser"
963 .into(),
964 )),
965 JsonClass::Case(Detection::Known(DetectedFormat::Transmission(format))) => {
966 transmission_json_target(format)
967 }
968 JsonClass::Case(Detection::Known(DetectedFormat::Distribution(format))) => {
969 Err(Error::UnknownFormat(format!(
970 "JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
971 format.name()
972 )))
973 }
974 JsonClass::Case(Detection::Ambiguous) => Err(Error::UnknownFormat(
975 "ambiguous JSON markers; pass an explicit source format".into(),
976 )),
977 JsonClass::Case(Detection::Unknown) => Err(Error::UnknownFormat(
978 "cannot infer JSON format; pass an explicit source format".into(),
979 )),
980 }
981}
982
983fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
984 match format {
985 TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
986 TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
987 TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
988 TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
989 TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
990 TransmissionFormat::DeepMindOpfDataJson => Ok(TargetFormat::DeepMindOpfDataJson),
991 TransmissionFormat::PsseRawx => Ok(TargetFormat::PsseRawx),
992 TransmissionFormat::Jiidm => Ok(TargetFormat::Jiidm),
993 other => Err(Error::UnknownFormat(format!(
994 "JSON classifier returned non-JSON transmission format `{}`",
995 other.name()
996 ))),
997 }
998}
999
1000#[derive(Debug, Clone)]
1002pub(crate) struct TextEmission {
1003 pub(crate) text: String,
1004 pub(crate) diagnostics: Vec<Diagnostic>,
1005 pub(crate) fidelity: powerio_core::Fidelity,
1006}
1007
1008impl TextEmission {
1009 pub(crate) fn new(text: String, diagnostics: Diagnostics) -> Self {
1010 Self {
1011 text,
1012 diagnostics: diagnostics.into_records(),
1013 fidelity: powerio_core::Fidelity::Canonical,
1014 }
1015 }
1016
1017 pub(crate) fn faithful(text: String) -> Self {
1019 let mut emission = Self::new(text, Diagnostics::new());
1020 emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
1021 emission
1022 }
1023
1024 #[cfg(test)]
1025 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
1026 crate::diagnostics::render_diagnostics(&self.diagnostics)
1027 }
1028
1029 pub(crate) fn push(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
1031 self.diagnostics.push(Diagnostic::of(info, message));
1032 }
1033
1034 pub(crate) fn prepend(&mut self, read: Vec<Diagnostic>) {
1036 let mut records = read;
1037 records.append(&mut self.diagnostics);
1038 self.diagnostics = records;
1039 }
1040}
1041
1042#[derive(Debug, Clone, Default)]
1047pub struct EmitOptions {
1048 pub missing_gen_cost: MissingGenCostPolicy,
1049 pub gen_cost_patches: Vec<GenCostPatch>,
1050}
1051
1052impl EmitOptions {
1053 #[must_use]
1054 pub fn is_default(&self) -> bool {
1055 self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
1056 }
1057}
1058
1059fn emit_text(
1067 module: &PioModule<BalancedNetwork>,
1068 format: TargetFormat,
1069) -> std::result::Result<TextEmission, powerio_core::Error> {
1070 if let Some(text) = echo_text(module, format) {
1071 return Ok(TextEmission::faithful(text));
1072 }
1073 let mut conv = emit_value_text(module.value(), format).map_err(core_error)?;
1074 warn_psse_downgrade(module, format, &mut conv);
1075 Ok(conv)
1076}
1077
1078pub(crate) fn core_error(error: Error) -> powerio_core::Error {
1080 let message = error.to_string();
1081 powerio_core::Error::new(error.code(), message).with_cause(error)
1082}
1083
1084fn echo_text(module: &PioModule<BalancedNetwork>, target: TargetFormat) -> Option<String> {
1088 let source = module.source()?;
1089 let buffer = source.primary_buffer().ok()?;
1090 let source_format = source
1091 .format()
1092 .and_then(|format| parse_target_format(format.as_str()))?;
1093 if !same_target_format(target, source_format) {
1094 return None;
1095 }
1096 let text = std::str::from_utf8(buffer.bytes()).ok()?;
1097 if let TargetFormat::Psse { rev } = target
1101 && (!matches!(rev, 33..=35)
1102 || psse::header_rev(text.trim_start_matches('\u{feff}')).ok()? != rev)
1103 {
1104 return None;
1105 }
1106 Some(text.to_owned())
1107}
1108
1109fn sincal_source_declared(source: &powerio_core::Source) -> bool {
1113 match source.format() {
1114 Some(format) => {
1115 format.as_str().eq_ignore_ascii_case("sincal")
1116 || routing::parse_transmission_format(format.as_str())
1117 == Some(TransmissionFormat::SincalBalanced)
1118 }
1119 None => std::path::Path::new(source.name())
1120 .extension()
1121 .is_some_and(|e| e.eq_ignore_ascii_case("sinx")),
1122 }
1123}
1124
1125fn sincal_feature_disabled(source: &powerio_core::Source) -> powerio_core::Error {
1127 powerio_core::Error::new(
1128 &codes::REQUEST_SINCAL_FEATURE_DISABLED,
1129 "SINCAL adapters live in powerio-tnd; use powerio with its `sincal` feature or powerio_tnd::formats::sincal",
1130 )
1131 .with_source(source.clone())
1132}
1133
1134fn sincal_source_omitted(net: &BalancedNetwork) -> Option<Diagnostic> {
1135 (net.source_format() == SourceFormat::Sincal).then(|| Diagnostic::of(
1136 &codes::EMIT_SINCAL_RETAINED_SOURCE_OMITTED,
1137 "Cross-format emission includes the typed electrical profile; fault, dynamic, protection, diagram, result and other native source-only data are omitted.",
1138 ))
1139}
1140
1141pub(crate) fn emit_value_text(net: &BalancedNetwork, format: TargetFormat) -> Result<TextEmission> {
1143 let mut conv = match format {
1144 TargetFormat::PowerModelsJson => write_powermodels_json(net),
1145 TargetFormat::EgretJson => write_egret_json(net),
1146 TargetFormat::Psse { rev } => {
1147 if !matches!(rev, 33..=35) {
1148 return Err(Error::Emit {
1149 format: "PSS/E .raw",
1150 message: format!(
1151 "unsupported revision {rev}; emission supports only revisions 33, 34, and 35"
1152 ),
1153 });
1154 }
1155 net.check_base_mva()?;
1156 write_psse_rev(net, rev)
1157 }
1158 TargetFormat::PsseRawx => {
1159 net.check_base_mva()?;
1160 write_rawx(net)?
1161 }
1162 TargetFormat::PowerWorld => write_powerworld(net),
1163 TargetFormat::PandapowerJson => write_pandapower_json(net),
1164 TargetFormat::Matpower => matpower::write_matpower_conversion(net),
1168 TargetFormat::Pslf => write_pslf(net),
1169 TargetFormat::SurgeJson => write_surge_json(net),
1170 TargetFormat::RavensJson => write_ravens_json(net),
1171 TargetFormat::Goc3Json => {
1172 return Err(Error::WriteUnsupported {
1173 format: "goc3-json",
1174 });
1175 }
1176 TargetFormat::DeepMindOpfDataJson => {
1177 return Err(Error::WriteUnsupported {
1178 format: "opfdata-json",
1179 });
1180 }
1181 TargetFormat::Xiidm => write_xiidm(net)?,
1182 TargetFormat::Jiidm => write_jiidm(net)?,
1183 TargetFormat::Cgmes => {
1184 return Err(Error::WriteUnsupported { format: "cgmes" });
1185 }
1186 TargetFormat::Ucte => ucte::write_ucte(net)?,
1187 };
1188 append_emit_warnings(net, format, &mut conv);
1189 Ok(conv)
1190}
1191
1192fn append_emit_warnings(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1194 conv.diagnostics.extend(sincal_source_omitted(net));
1195 warn_normalized_tap(net, format, conv);
1196 warn_missing_reference(net, format, conv);
1197 warn_dropped_frequency(net, format, conv);
1198 warn_dropped_locations(net, format, conv);
1199 warn_dropped_transformer_charging(net, format, conv);
1200 warn_dropped_exchange_context(net, format, conv);
1201}
1202
1203pub fn emit(
1215 module: &PioModule<BalancedNetwork>,
1216 format: TargetFormat,
1217 destination: powerio_core::Destination,
1218) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1219 emit_with_options(module, format, &EmitOptions::default(), destination)
1220}
1221
1222pub fn emit_with_options(
1231 module: &PioModule<BalancedNetwork>,
1232 format: TargetFormat,
1233 options: &EmitOptions,
1234 destination: powerio_core::Destination,
1235) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1236 if format == TargetFormat::Cgmes
1237 && options.is_default()
1238 && let Some(source) = module.source()
1239 && source
1240 .format()
1241 .is_some_and(|value| value.as_str() == "cgmes")
1242 {
1243 if source.is_directory() {
1244 let mut artifacts = Vec::new();
1245 for name in source.entry_names()? {
1246 let buffer = source.buffer(&name)?;
1247 artifacts.push(powerio_core::MemoryArtifact::new(
1248 name,
1249 buffer.bytes().to_vec(),
1250 ));
1251 }
1252 return destination.__commit_artifacts(
1253 true,
1254 powerio_core::Fidelity::ExactSameFormat,
1255 artifacts,
1256 Vec::new(),
1257 );
1258 }
1259 if source.acquired_buffers().len() == 1 {
1260 let buffer = source.primary_buffer()?;
1261 let file_name = std::path::Path::new(buffer.name())
1262 .file_name()
1263 .and_then(|name| name.to_str())
1264 .and_then(|name| powerio_core::ArtifactPath::new(name).ok())
1265 .unwrap_or_else(|| {
1266 powerio_core::ArtifactPath::new("case.zip")
1267 .expect("static name is a valid artifact path")
1268 });
1269 let artifact = powerio_core::MemoryArtifact::new(file_name, buffer.bytes().to_vec());
1270 return destination.__commit_artifacts(
1271 false,
1272 powerio_core::Fidelity::ExactSameFormat,
1273 vec![artifact],
1274 Vec::new(),
1275 );
1276 }
1277 }
1278 if matches!(format, TargetFormat::Xiidm | TargetFormat::Jiidm)
1279 && options.is_default()
1280 && let Some(source) = module.source()
1281 && source
1282 .format()
1283 .and_then(|value| parse_target_format(value.as_str()))
1284 == Some(format)
1285 && source.acquired_buffers().len() == 1
1286 {
1287 let buffer = source.primary_buffer()?;
1288 let artifact = powerio_core::MemoryArtifact::new(
1289 powerio_core::ArtifactPath::new(format!("case.{}", format.extension()))
1290 .expect("the format extension names a valid artifact path"),
1291 buffer.bytes().to_vec(),
1292 );
1293 return destination.__commit_artifacts(
1294 false,
1295 powerio_core::Fidelity::ExactSameFormat,
1296 vec![artifact],
1297 Vec::new(),
1298 );
1299 }
1300 if format == TargetFormat::Cgmes {
1301 let (working, mut diagnostics) = if options.is_default() {
1302 (module.value().clone(), Vec::new())
1303 } else {
1304 apply_emit_cost_policy(module.value(), options).map_err(core_error)?
1305 };
1306 let (artifacts, format_diagnostics) = cgmes::artifacts(&working).map_err(core_error)?;
1307 diagnostics.extend(format_diagnostics.into_records());
1308 diagnostics.extend(sincal_source_omitted(&working));
1309 powerio_core::attach_retention_details(
1310 module.diagnostics(),
1311 &mut diagnostics,
1312 "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1313 )?;
1314 return destination.__commit_artifacts(
1315 true,
1316 powerio_core::Fidelity::Canonical,
1317 artifacts,
1318 diagnostics,
1319 );
1320 }
1321 let mut conv = emit_text_with_options(module, format, options)?;
1322 powerio_core::attach_retention_details(
1323 module.diagnostics(),
1324 &mut conv.diagnostics,
1325 "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1326 )?;
1327 let artifact = powerio_core::MemoryArtifact::new(
1328 powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1329 conv.text.into_bytes(),
1330 );
1331 destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
1332}
1333
1334#[doc(hidden)]
1346pub fn __emit_pypsa_csv(
1347 module: &PioModule<BalancedNetwork>,
1348 destination: powerio_core::Destination,
1349) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1350 __emit_pypsa_csv_with_options(module, &EmitOptions::default(), destination)
1351}
1352
1353#[doc(hidden)]
1355pub fn __emit_pypsa_csv_with_options(
1356 module: &PioModule<BalancedNetwork>,
1357 options: &EmitOptions,
1358 destination: powerio_core::Destination,
1359) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
1360 let (working, mut diagnostics) = if options.is_default() {
1361 (None, Vec::new())
1362 } else {
1363 let (network, diagnostics) =
1364 apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1365 (Some(network), diagnostics)
1366 };
1367 let (artifacts, format_diagnostics) =
1368 pypsa::pypsa_csv_artifacts(working.as_ref().unwrap_or(module.value()));
1369 diagnostics.extend(format_diagnostics);
1370 diagnostics.extend(sincal_source_omitted(module.value()));
1371 powerio_core::attach_retention_details(
1372 module.diagnostics(),
1373 &mut diagnostics,
1374 "EMIT.SINCAL.RETAINED_SOURCE_OMITTED",
1375 )?;
1376 let artifacts = artifacts
1377 .into_iter()
1378 .map(|(name, text)| {
1379 powerio_core::MemoryArtifact::new(
1380 powerio_core::ArtifactPath::new(name).expect("the writer emits fixed valid names"),
1381 text.into_bytes(),
1382 )
1383 })
1384 .collect();
1385 destination.__commit_artifacts(
1386 true,
1387 powerio_core::Fidelity::Canonical,
1388 artifacts,
1389 diagnostics,
1390 )
1391}
1392
1393fn emit_text_with_options(
1398 module: &PioModule<BalancedNetwork>,
1399 format: TargetFormat,
1400 options: &EmitOptions,
1401) -> std::result::Result<TextEmission, powerio_core::Error> {
1402 if options.is_default() {
1403 return emit_text(module, format);
1404 }
1405 let (working, policy_warnings) =
1406 apply_emit_cost_policy(module.value(), options).map_err(core_error)?;
1407 let mut conv = emit_value_text(&working, format).map_err(core_error)?;
1408 conv.prepend(policy_warnings);
1409 Ok(conv)
1410}
1411
1412pub(crate) fn apply_emit_cost_policy(
1417 net: &BalancedNetwork,
1418 options: &EmitOptions,
1419) -> Result<(BalancedNetwork, Vec<Diagnostic>)> {
1420 let mut working = net.clone();
1421 let report =
1422 working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
1423 let mut policy_warnings = Diagnostics::new();
1424 if report.patched > 0 {
1425 policy_warnings.push(
1426 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1427 format!(
1428 "generator cost patch applied to {} generator(s)",
1429 report.patched
1430 ),
1431 );
1432 }
1433 if report.synthesized > 0 {
1434 policy_warnings.push(
1435 &codes::TRANSFORM_GEN_COST_POLICY_APPLIED,
1436 match options.missing_gen_cost {
1437 MissingGenCostPolicy::Fill {
1438 c2,
1439 c1,
1440 c0,
1441 startup,
1442 shutdown,
1443 } => format!(
1444 "generator cost synthesized for {} generator(s): model 2, ncost 3, \
1445 coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
1446 report.synthesized
1447 ),
1448 _ => unreachable!("only Fill synthesizes costs"),
1449 },
1450 );
1451 }
1452 Ok((working, policy_warnings.into_records()))
1453}
1454
1455pub(crate) fn allocate_circuit_id<K: Ord + Clone>(
1461 preferred: Option<&str>,
1462 key: K,
1463 used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
1464) -> String {
1465 let taken = used.entry(key).or_default();
1466 if let Some(id) = preferred
1467 && taken.insert(id.to_owned())
1468 {
1469 return id.to_owned();
1470 }
1471 let mut n = 1u32;
1472 loop {
1473 let candidate = n.to_string();
1474 if taken.insert(candidate.clone()) {
1475 return candidate;
1476 }
1477 n += 1;
1478 }
1479}
1480
1481fn warn_psse_downgrade(
1489 module: &PioModule<BalancedNetwork>,
1490 format: TargetFormat,
1491 conv: &mut TextEmission,
1492) {
1493 let source_text = module
1494 .source()
1495 .and_then(|source| source.primary_buffer().ok())
1496 .and_then(|buffer| String::from_utf8(buffer.content_bytes().to_vec()).ok());
1497 if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) = (
1498 format,
1499 module.value().source_format(),
1500 source_text.as_deref(),
1501 ) && let Ok(src_rev) = psse::header_rev(src)
1502 && src_rev > rev
1503 {
1504 conv.push(
1505 &codes::EMIT_PSSE_DOWNGRADED,
1506 format!(
1507 "PSS/E source is revision {src_rev} but the emission target is revision {rev}; \
1508 the older layout drops fields the source carried (emit as psse{src_rev} to keep them)"
1509 ),
1510 );
1511 }
1512}
1513
1514fn warn_dropped_frequency(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1520 let carries_frequency = matches!(
1521 format,
1522 TargetFormat::Psse { .. }
1523 | TargetFormat::PsseRawx
1524 | TargetFormat::PandapowerJson
1525 | TargetFormat::PowerModelsJson
1526 | TargetFormat::EgretJson
1527 );
1528 if carries_frequency {
1529 return;
1530 }
1531 if format == TargetFormat::Ucte {
1534 if (net.base_frequency() - 50.0).abs() > 1e-9 {
1535 conv.push(
1536 &format.emit_family().field_dropped,
1537 format!(
1538 "system base frequency {} Hz dropped: UCTE-DEF describes the 50 Hz synchronous area and has no frequency field (reads back as 50 Hz)",
1539 net.base_frequency()
1540 ),
1541 );
1542 }
1543 return;
1544 }
1545 if (net.base_frequency() - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
1546 conv.push(
1547 &format.emit_family().field_dropped,
1548 format!(
1549 "system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
1550 net.base_frequency(),
1551 format.label(),
1552 crate::network::DEFAULT_BASE_FREQUENCY
1553 ),
1554 );
1555 }
1556}
1557
1558fn warn_dropped_locations(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
1565 let carries_locations = matches!(
1567 format,
1568 TargetFormat::PowerWorld
1569 | TargetFormat::PandapowerJson
1570 | TargetFormat::PowerModelsJson
1571 | TargetFormat::EgretJson
1572 );
1573 if carries_locations {
1574 return;
1575 }
1576 let n = net.buses().iter().filter(|b| b.location.is_some()).count();
1577 let routed = net.branches().iter().filter(|b| b.route.is_some()).count();
1578 if n > 0 || routed > 0 {
1579 conv.push(
1580 &format.emit_family().field_dropped,
1581 format!(
1582 "{n} bus location(s) and {routed} branch route(s) dropped: {} has no \
1583 coordinate field (emit a .geo.json sidecar to keep them)",
1584 format.label()
1585 ),
1586 );
1587 }
1588}
1589
1590fn warn_dropped_transformer_charging(
1596 net: &BalancedNetwork,
1597 format: TargetFormat,
1598 conv: &mut TextEmission,
1599) {
1600 if !matches!(format, TargetFormat::Pslf) {
1601 return;
1602 }
1603 let n = net
1604 .branches()
1605 .iter()
1606 .filter(|b| b.is_transformer() && b.calc_total_charging_b() != 0.0)
1607 .count();
1608 if n > 0 {
1609 conv.push(
1610 &codes::EMIT_PSLF.field_dropped,
1611 format!(
1612 "{n} transformer(s) carry line charging that the PSLF .epc transformer \
1613 record cannot represent; the charging was dropped"
1614 ),
1615 );
1616 }
1617}
1618
1619fn warn_dropped_exchange_context(
1624 net: &BalancedNetwork,
1625 format: TargetFormat,
1626 conv: &mut TextEmission,
1627) {
1628 if !matches!(
1629 format,
1630 TargetFormat::Matpower
1631 | TargetFormat::PowerModelsJson
1632 | TargetFormat::EgretJson
1633 | TargetFormat::PowerWorld
1634 | TargetFormat::PandapowerJson
1635 | TargetFormat::Pslf
1636 | TargetFormat::SurgeJson
1637 | TargetFormat::RavensJson
1638 | TargetFormat::Ucte
1639 ) {
1640 return;
1641 }
1642 let message = match format {
1643 TargetFormat::RavensJson => {
1646 exchange_context_loss_carrying(net, format.label(), CarriedContext::Identities)
1647 }
1648 TargetFormat::PowerModelsJson | TargetFormat::EgretJson => {
1653 exchange_context_loss_carrying(net, format.label(), CarriedContext::AllButSolver)
1654 }
1655 _ => exchange_context_loss(net, format.label()),
1656 };
1657 if format == TargetFormat::EgretJson {
1658 let omitted_uids = net
1659 .switches()
1660 .iter()
1661 .map(|v| v.uid.as_deref())
1662 .chain(net.storage().iter().map(|v| v.uid.as_deref()))
1663 .chain(net.transformers_3w().iter().map(|v| v.uid.as_deref()))
1664 .chain(
1665 net.static_var_compensators()
1666 .iter()
1667 .map(|v| v.uid.as_deref()),
1668 )
1669 .filter(|uid| uid.is_some() && !net.uid_is_generated(*uid))
1670 .count();
1671 if omitted_uids > 0 {
1672 conv.push(&format.emit_family().field_dropped, format!(
1673 "Egret omits {omitted_uids} source-assigned component identity value(s) on switches, storage, static var compensators or three-winding transformers; retained detailed connectivity may still reference these absent electrical rows"
1674 ));
1675 }
1676 }
1677 if let Some(message) = message {
1678 conv.push(&format.emit_family().field_dropped, message);
1679 }
1680}
1681
1682pub(super) fn exchange_context_loss(net: &BalancedNetwork, target: &str) -> Option<String> {
1686 exchange_context_loss_carrying(net, target, CarriedContext::Nothing)
1687}
1688
1689#[derive(Clone, Copy, PartialEq, Eq)]
1692enum CarriedContext {
1693 Nothing,
1694 Identities,
1696 AllButSolver,
1699}
1700
1701fn detailed_connectivity_records(detailed: &crate::network::DetailedConnectivity) -> usize {
1703 [
1704 detailed.omitted_fields.len(),
1705 detailed.component_metadata.len(),
1706 detailed.subnetworks.len(),
1707 detailed.substations.len(),
1708 detailed.voltage_levels.len(),
1709 detailed.bus_breaker_buses.len(),
1710 detailed.calculated_buses.len(),
1711 detailed.connectivity_nodes.len(),
1712 detailed.busbar_sections.len(),
1713 detailed.junctions.len(),
1714 detailed.terminals.len(),
1715 detailed.switches.len(),
1716 detailed.internal_connections.len(),
1717 detailed.operational_limit_groups.len(),
1718 detailed.tap_changers.len(),
1719 detailed.equipment_reactive_limits.len(),
1720 detailed.boundary_lines.len(),
1721 detailed.tie_lines.len(),
1722 detailed.dc_converter_units.len(),
1723 detailed.dc_topological_nodes.len(),
1724 detailed.dc_nodes.len(),
1725 detailed.dc_grounds.len(),
1726 detailed.dc_busbars.len(),
1727 detailed.dc_lines.len(),
1728 detailed.dc_series_devices.len(),
1729 detailed.dc_switches.len(),
1730 detailed.voltage_source_converters.len(),
1731 detailed.line_commutated_converters.len(),
1732 ]
1733 .into_iter()
1734 .sum()
1735}
1736
1737pub(super) fn carried_detailed_connectivity(net: &BalancedNetwork) -> Option<serde_json::Value> {
1742 let detailed = net.detailed_connectivity().as_deref()?;
1743 (detailed_connectivity_records(detailed) > 0).then(|| {
1744 serde_json::to_value(detailed).expect("the detailed connectivity hierarchy is plain data")
1745 })
1746}
1747
1748#[allow(clippy::too_many_lines)] fn exchange_context_loss_carrying(
1750 net: &BalancedNetwork,
1751 target: &str,
1752 carried: CarriedContext,
1753) -> Option<String> {
1754 let mut dropped = Vec::new();
1755 let default_metadata = crate::network::CaseMetadata::default();
1756 let carries_all = carried == CarriedContext::AllButSolver;
1757 let metadata = if carries_all {
1758 &default_metadata
1759 } else {
1760 net.case_metadata()
1761 };
1762 let mut metadata_fields = Vec::new();
1763 if metadata.case_date.is_some() {
1764 metadata_fields.push("case_date");
1765 }
1766 if metadata.forecast_distance.is_some() {
1767 metadata_fields.push("forecast_distance");
1768 }
1769 if metadata.source_model_format.is_some() {
1770 metadata_fields.push("source_model_format");
1771 }
1772 if metadata.minimum_validation_level.is_some() {
1773 metadata_fields.push("minimum_validation_level");
1774 }
1775 if !metadata_fields.is_empty() {
1776 dropped.push(format!(
1777 "case metadata fields `{}`",
1778 metadata_fields.join("`, `")
1779 ));
1780 }
1781
1782 if let Some(detailed) = net
1783 .detailed_connectivity()
1784 .as_deref()
1785 .filter(|_| !carries_all)
1786 {
1787 let records = detailed_connectivity_records(detailed);
1788 if records > 0 {
1789 dropped.push(format!(
1790 "the `detailed_connectivity` hierarchy and topology ({records} record(s))"
1791 ));
1792 }
1793 }
1794
1795 let source_uids = net
1796 .buses()
1797 .iter()
1798 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1799 .count()
1800 + net
1801 .loads()
1802 .iter()
1803 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1804 .count()
1805 + net
1806 .shunts()
1807 .iter()
1808 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1809 .count()
1810 + net
1811 .static_var_compensators()
1812 .iter()
1813 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1814 .count()
1815 + net
1816 .branches()
1817 .iter()
1818 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1819 .count()
1820 + net
1821 .switches()
1822 .iter()
1823 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1824 .count()
1825 + net
1826 .generators()
1827 .iter()
1828 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1829 .count()
1830 + net
1831 .storage()
1832 .iter()
1833 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1834 .count()
1835 + net
1836 .hvdc()
1837 .iter()
1838 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1839 .count()
1840 + net
1841 .transformers_3w()
1842 .iter()
1843 .filter(|value| value.uid.is_some() && !net.uid_is_generated(value.uid.as_deref()))
1844 .count()
1845 + net
1846 .areas()
1847 .iter()
1848 .filter(|value| value.uid.is_some())
1849 .count();
1850 if source_uids > 0 && carried == CarriedContext::Nothing {
1851 dropped.push(format!(
1852 "{source_uids} source-assigned stable component identity value(s)"
1853 ));
1854 }
1855 if net.geo().is_some() && !carries_all {
1856 dropped.push("geographic coordinate reference metadata".to_owned());
1857 }
1858 if net.solver().is_some() {
1859 dropped.push("solver and solution-control metadata".to_owned());
1860 }
1861 if dropped.is_empty() {
1862 return None;
1863 }
1864 Some(format!(
1865 "{target} has no complete source-neutral grid exchange context; dropped {}",
1866 dropped.join(", ")
1867 ))
1868}
1869
1870pub(super) fn branch_rating_set_drop_warning(
1871 target: &str,
1872 branch_index: usize,
1873 branch: &Branch,
1874 rating: &BranchRatingSet,
1875) -> String {
1876 format!(
1877 "branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
1878 branch_index + 1,
1879 branch.from,
1880 branch.to,
1881 rating.name,
1882 rating.rate_mva,
1883 target
1884 )
1885}
1886
1887pub(super) fn warn_dropped_extras(
1894 family: &'static EmitFamily,
1895 target: &str,
1896 net: &BalancedNetwork,
1897 consumed: impl Fn(&str) -> bool,
1898 warnings: &mut Diagnostics,
1899) {
1900 let mut dropped = 0usize;
1901 let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1902 for extras in net
1903 .buses()
1904 .iter()
1905 .map(|e| &e.extras)
1906 .chain(net.branches().iter().map(|e| &e.extras))
1907 .chain(net.loads().iter().map(|e| &e.extras))
1908 .chain(net.shunts().iter().map(|e| &e.extras))
1909 .chain(net.switches().iter().map(|e| &e.extras))
1910 .chain(net.storage().iter().map(|e| &e.extras))
1911 .chain(net.hvdc().iter().map(|e| &e.extras))
1912 .chain(net.transformers_3w().iter().map(|e| &e.extras))
1913 {
1914 let mut carries = false;
1915 for key in extras.keys().filter(|key| !consumed(key)) {
1916 carries = true;
1917 keys.insert(key.as_str());
1918 }
1919 if carries {
1920 dropped += 1;
1921 }
1922 }
1923 if dropped > 0 {
1924 let named = keys
1925 .iter()
1926 .take(EXTRAS_KEYS_NAMED)
1927 .copied()
1928 .collect::<Vec<_>>()
1929 .join("`, `");
1930 let remainder = keys.len().saturating_sub(EXTRAS_KEYS_NAMED);
1931 let remainder = if remainder > 0 {
1932 format!(" and {remainder} more")
1933 } else {
1934 String::new()
1935 };
1936 warnings.push(
1937 &family.extras_dropped,
1938 format!(
1939 "{dropped} element(s) state field(s) `{named}`{remainder} that no {target} record \
1940 holds; dropped"
1941 ),
1942 );
1943 }
1944}
1945
1946const EXTRAS_KEYS_NAMED: usize = 6;
1950
1951pub(super) fn warn_dropped_areas(
1957 family: &'static EmitFamily,
1958 target: &str,
1959 writes_bus_area: bool,
1960 net: &BalancedNetwork,
1961 warnings: &mut Diagnostics,
1962) {
1963 if !writes_bus_area {
1964 if !net.areas().is_empty() {
1965 let count = net.areas().len();
1966 let noun = if count == 1 { "record" } else { "records" };
1967 warnings.push(
1968 &family.areas_dropped,
1969 format!(
1970 "{count} area {noun} dropped: {target} writes neither an area table nor a bus area number"
1971 ),
1972 );
1973 }
1974 return;
1975 }
1976 let mut fields = std::collections::BTreeSet::new();
1977 let mut stated = 0usize;
1978 for area in net.areas() {
1979 let mut states_any = false;
1980 for field in stated_area_fields(area) {
1981 fields.insert(field);
1982 states_any = true;
1983 }
1984 stated += usize::from(states_any);
1985 }
1986 if fields.is_empty() {
1987 return;
1988 }
1989 let total = net.areas().len();
1990 let subject = if total == 1 {
1991 "the area record states".to_owned()
1992 } else {
1993 format!("{stated} of {total} area records state")
1994 };
1995 warnings.push(
1996 &family.areas_dropped,
1997 format!(
1998 "{subject} {}: {target} bus rows carry the area number, but {target} has no record \
1999 for the area's attributes",
2000 fields.into_iter().collect::<Vec<_>>().join(", ")
2001 ),
2002 );
2003}
2004
2005fn stated_area_fields(area: &crate::Area) -> impl Iterator<Item = &'static str> {
2008 [
2009 (area.slack_bus.is_some(), "swing bus"),
2010 (area.net_interchange != 0.0, "scheduled net interchange"),
2011 (area.tolerance != 0.0, "interchange tolerance"),
2012 (area.name.is_some(), "name"),
2013 (area.uid.is_some(), "source identity"),
2014 (area.area_type.is_some(), "classification"),
2015 ]
2016 .into_iter()
2017 .filter_map(|(stated, field)| stated.then_some(field))
2018}
2019
2020pub(super) fn warn_extra_branch_rating_sets(
2021 family: &'static EmitFamily,
2022 target: &str,
2023 net: &BalancedNetwork,
2024 warnings: &mut Diagnostics,
2025) {
2026 for (branch_index, branch) in net.branches().iter().enumerate() {
2027 for rating in &branch.rating_sets {
2028 warnings.push(
2029 &family.rating_set_dropped,
2030 branch_rating_set_drop_warning(target, branch_index, branch, rating),
2031 );
2032 }
2033 }
2034}
2035
2036pub(super) fn warn_winding_rating_sets(
2039 family: &'static EmitFamily,
2040 target: &str,
2041 net: &BalancedNetwork,
2042 warnings: &mut Diagnostics,
2043) {
2044 for transformer in net.transformers_3w() {
2045 let [i, j, k] = transformer.windings.each_ref().map(|winding| winding.bus);
2046 for index in 0..3 {
2047 for rating in transformer.winding_rating_sets(index) {
2048 warnings.push(
2049 &family.rating_set_dropped,
2050 format!(
2051 "three winding transformer {i}-{j}-{k} winding {} rating set {}={} MVA dropped: {target} has no field for winding rating sets beyond rate_a, rate_b, and rate_c",
2052 index + 1,
2053 rating.name,
2054 rating.rate_mva
2055 ),
2056 );
2057 }
2058 }
2059 }
2060}
2061
2062pub(super) fn warn_winding_status_collapsed(
2066 family: &'static EmitFamily,
2067 target: &str,
2068 net: &BalancedNetwork,
2069 warnings: &mut Diagnostics,
2070) {
2071 let windings = net
2072 .transformers_3w()
2073 .iter()
2074 .filter(|transformer| transformer.in_service)
2075 .flat_map(Transformer3W::winding_in_service)
2076 .filter(|in_service| !in_service)
2077 .count();
2078 if windings > 0 {
2079 warnings.push(
2080 &family.value_collapsed,
2081 format!(
2082 "{windings} out of service three winding transformer winding(s) written in service: the {target} writer states one status per three winding transformer"
2083 ),
2084 );
2085 }
2086}
2087
2088pub fn parse_format_id(
2093 token: &str,
2094) -> std::result::Result<powerio_core::FormatId, powerio_core::Error> {
2095 powerio_core::FormatId::new(token.to_ascii_lowercase().replace('_', "-"))
2096}
2097
2098fn warn_missing_reference(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
2104 let needs_ref = matches!(
2105 format,
2106 TargetFormat::Matpower
2107 | TargetFormat::Psse { .. }
2108 | TargetFormat::PsseRawx
2109 | TargetFormat::PowerModelsJson
2110 | TargetFormat::PandapowerJson
2111 | TargetFormat::Pslf
2112 | TargetFormat::SurgeJson
2113 );
2114 if needs_ref && let Some(message) = missing_reference_warning(net) {
2115 conv.push(&format.emit_family().reference_missing, message);
2116 }
2117}
2118
2119pub(super) fn missing_reference_warning(net: &BalancedNetwork) -> Option<String> {
2121 (!net.buses().iter().any(|b| b.kind == BusType::Ref)).then(|| {
2122 "no reference (slack) bus in the source network; power flow tools \
2123 reject such cases; to_normalized synthesizes a slack at the \
2124 largest pmax in service generator bus"
2125 .to_string()
2126 })
2127}
2128
2129#[allow(clippy::float_cmp)]
2141fn warn_normalized_tap(net: &BalancedNetwork, format: TargetFormat, conv: &mut TextEmission) {
2142 if matches!(format, TargetFormat::Matpower) {
2143 return;
2144 }
2145 if let Some(message) = normalized_tap_warning(net) {
2146 conv.push(&format.emit_family().element_relabeled, message);
2147 }
2148}
2149
2150#[allow(clippy::float_cmp)]
2154pub(super) fn normalized_tap_warning(net: &BalancedNetwork) -> Option<String> {
2155 if !net.is_normalized() {
2156 return None;
2157 }
2158 let ambiguous = net
2162 .branches()
2163 .iter()
2164 .filter(|b| b.tap == 1.0 && b.shift == 0.0)
2165 .count();
2166 (ambiguous > 0).then(|| {
2167 format!(
2168 "normalized network: {ambiguous} branch(es) have unit tap and no phase \
2169 shift, so the line/transformer label is not preserved (the power flow \
2170 is identical)"
2171 )
2172 })
2173}
2174
2175pub(super) fn nonzero_differs(value: f64, reference: f64) -> bool {
2179 value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
2180}
2181
2182pub(crate) fn set_bus_kind(
2185 buses: &mut [Bus],
2186 bus_pos: &HashMap<BusId, usize>,
2187 bus: BusId,
2188 kind: BusType,
2189) {
2190 if let Some(&idx) = bus_pos.get(&bus)
2191 && buses[idx].kind != BusType::Isolated
2192 {
2193 buses[idx].kind = kind;
2194 }
2195}
2196
2197pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
2199 bus_pos
2200 .get(&bus)
2201 .and_then(|&i| buses.get(i))
2202 .map_or(0.0, |b| b.base_kv)
2203}
2204
2205pub(crate) fn sanitize_quoted<'a>(
2223 value: &'a str,
2224 forbidden: &[char],
2225 replacement: char,
2226) -> std::borrow::Cow<'a, str> {
2227 let breaks = |c: char| c == '\n' || c == '\r' || forbidden.contains(&c);
2228 if value.contains(breaks) {
2229 value
2230 .chars()
2231 .map(|c| if breaks(c) { replacement } else { c })
2232 .collect::<String>()
2233 .into()
2234 } else {
2235 std::borrow::Cow::Borrowed(value)
2236 }
2237}
2238
2239pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
2242 if v_kv > 0.0 && base_mva > 0.0 {
2243 v_kv * v_kv / base_mva
2244 } else {
2245 1.0
2246 }
2247}
2248
2249fn same_target_format(requested: TargetFormat, source: TargetFormat) -> bool {
2253 requested == source
2254 || matches!(
2255 (requested, source),
2256 (TargetFormat::Psse { .. }, TargetFormat::Psse { .. })
2257 )
2258}
2259
2260pub(crate) fn jnum(x: f64) -> Value {
2262 serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
2263}
2264
2265pub(crate) fn finish(
2269 family: &'static EmitFamily,
2270 root: Map<String, Value>,
2271 mut warnings: Diagnostics,
2272) -> TextEmission {
2273 let value = Value::Object(root);
2274 warn_nonfinite_json(family, &value, &mut warnings);
2275 let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
2276 TextEmission::new(text, warnings)
2277}
2278
2279fn warn_nonfinite_json(family: &'static EmitFamily, value: &Value, warnings: &mut Diagnostics) {
2281 let mut nulls = BTreeSet::new();
2282 collect_null_keys(value, &mut nulls);
2283 if !nulls.is_empty() {
2284 let finding = Diagnostic::of(
2285 &family.not_a_number,
2286 format!(
2287 "non-finite numeric values written as JSON null in field(s): {}",
2288 nulls.into_iter().collect::<Vec<_>>().join(", ")
2289 ),
2290 );
2291 if !warnings.records().contains(&finding) {
2292 warnings.record(finding);
2293 }
2294 }
2295}
2296
2297fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
2299 match value {
2300 Value::Object(map) => {
2301 for (key, val) in map {
2302 if val.is_null() {
2303 out.insert(key.clone());
2304 } else {
2305 collect_null_keys(val, out);
2306 }
2307 }
2308 }
2309 Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
2310 _ => {}
2311 }
2312}
2313
2314#[cfg(test)]
2316pub(crate) mod test_parse {
2317 use super::*;
2318
2319 #[derive(Debug)]
2320 pub(crate) struct TestParsed {
2321 pub network: BalancedNetwork,
2322 pub diagnostics: Vec<Diagnostic>,
2323 }
2324
2325 impl TestParsed {
2326 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
2327 crate::diagnostics::render_diagnostics(&self.diagnostics)
2328 }
2329 }
2330
2331 fn declared(
2332 source: powerio_core::Source,
2333 from: Option<&str>,
2334 ) -> std::result::Result<powerio_core::Source, powerio_core::Error> {
2335 match from {
2336 None => Ok(source),
2337 Some(token) => Ok(source.with_format(powerio_core::FormatId::new(
2338 token.to_ascii_lowercase().replace('_', "-"),
2339 )?)),
2340 }
2341 }
2342
2343 pub(crate) fn parse_file(
2344 path: impl AsRef<std::path::Path>,
2345 from: Option<&str>,
2346 ) -> std::result::Result<TestParsed, powerio_core::Error> {
2347 let source = declared(powerio_core::Source::open(path.as_ref())?, from)?;
2348 parse(source).map(|module| TestParsed {
2349 diagnostics: module.diagnostics().to_vec(),
2350 network: module.into_value(),
2351 })
2352 }
2353
2354 pub(crate) fn parse_str(
2355 text: &str,
2356 from: &str,
2357 ) -> std::result::Result<TestParsed, powerio_core::Error> {
2358 let source = declared(
2359 powerio_core::Source::from_memory("<memory>", text.as_bytes().to_vec())?,
2360 Some(from),
2361 )?;
2362 parse(source).map(|module| TestParsed {
2363 diagnostics: module.diagnostics().to_vec(),
2364 network: module.into_value(),
2365 })
2366 }
2367}
2368
2369#[cfg(test)]
2370mod tests {
2371 use super::test_parse::{parse_file, parse_str};
2372 use super::*;
2373 use crate::network::SourceFormat;
2374
2375 #[test]
2376 fn sanitize_quoted_always_replaces_line_terminators() {
2377 for forbidden in [&[][..], &['\''][..], &['"'][..]] {
2381 let out = sanitize_quoted("A\n42, 'X'\r\nB", forbidden, ' ');
2382 assert!(
2383 !out.contains('\n') && !out.contains('\r'),
2384 "terminator survived with forbidden={forbidden:?}: {out:?}"
2385 );
2386 }
2387 assert!(matches!(
2389 sanitize_quoted("clean name", &['\''], ' '),
2390 std::borrow::Cow::Borrowed(_)
2391 ));
2392 }
2393
2394 #[test]
2395 fn dss_extension_error_names_the_distribution_surface() {
2396 let path = std::env::temp_dir().join(format!(
2397 "powerio-dss-surface-{}-feeder.dss",
2398 std::process::id()
2399 ));
2400 std::fs::write(&path, "New Circuit.feeder\n").unwrap();
2401 let err = parse_file(&path, None).unwrap_err();
2402 let _ = std::fs::remove_file(&path);
2403 assert!(err.to_string().contains("distribution"), "got: {err}");
2404 }
2405
2406 #[test]
2407 fn io_error_names_the_path() {
2408 let path =
2409 std::env::temp_dir().join(format!("powerio-no-such-case-{}.m", std::process::id()));
2410 let err = parse_file(&path, None).unwrap_err();
2411 assert_eq!(err.category(), powerio_core::ErrorCategory::Io);
2412 let msg = err.to_string();
2413 assert!(
2414 msg.contains(&path.display().to_string()),
2415 "the io failure must name the path: {msg}"
2416 );
2417 }
2418
2419 #[test]
2420 fn a_directory_is_refused_as_a_directory() {
2421 let dir = std::env::temp_dir().join(format!("pglib-opf-23.07-{}", std::process::id()));
2424 std::fs::create_dir_all(&dir).unwrap();
2425 let err = parse_file(&dir, None).unwrap_err();
2426 std::fs::remove_dir_all(&dir).unwrap();
2427 let msg = err.to_string();
2428 assert!(msg.contains("is a directory"), "got: {msg}");
2429 assert!(msg.contains(&dir.display().to_string()), "got: {msg}");
2430 assert!(msg.contains("PyPSA CSV folder"), "got: {msg}");
2431 }
2432
2433 #[test]
2434 fn unknown_format_error_lists_the_accepted_names() {
2435 let err = parse_str("anything", "not-a-format").unwrap_err();
2436 let msg = err.to_string();
2437 assert!(msg.contains("not-a-format"), "got: {msg}");
2438 assert!(msg.contains("accepted names:"), "got: {msg}");
2439 assert!(msg.contains(SOURCE_FORMAT_NAMES), "got: {msg}");
2440 }
2441
2442 #[test]
2443 fn the_accepted_name_list_matches_the_matcher() {
2444 use routing::TransmissionFormat as TF;
2445 let mut canonical = Vec::new();
2447 for clause in SOURCE_FORMAT_NAMES.split(", ") {
2448 for (i, alias) in clause.split('/').enumerate() {
2449 let resolved = routing::parse_transmission_format(alias);
2450 assert!(
2451 resolved.is_some(),
2452 "listed alias `{alias}` does not resolve"
2453 );
2454 if i == 0 {
2455 canonical.push(resolved.unwrap());
2456 }
2457 }
2458 }
2459 for format in [
2462 TF::Matpower,
2463 TF::PowerModelsJson,
2464 TF::EgretJson,
2465 TF::Psse,
2466 TF::Psse34,
2467 TF::Psse35,
2468 TF::PowerWorld,
2469 TF::PandapowerJson,
2470 TF::PypsaCsv,
2471 TF::Pslf,
2472 TF::Pwb,
2473 TF::Goc3Json,
2474 TF::SurgeJson,
2475 TF::RavensJson,
2476 TF::DeepMindOpfDataJson,
2477 TF::Ucte,
2478 TF::Xiidm,
2479 TF::Jiidm,
2480 TF::Cgmes,
2481 TF::IeeeCdf,
2482 ] {
2483 assert!(
2484 canonical.contains(&format),
2485 "{} is missing from SOURCE_FORMAT_NAMES",
2486 format.name()
2487 );
2488 }
2489 }
2490
2491 #[test]
2492 fn a_case_with_generators_and_no_cost_data_warns() {
2493 let costless = "\
2494function mpc = nocost
2495mpc.version = '2';
2496mpc.baseMVA = 100;
2497mpc.bus = [
2498\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2499\t2\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2500];
2501mpc.gen = [
2502\t1\t60\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2503];
2504mpc.branch = [
2505\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2506];
2507";
2508 let parsed = parse_str(costless, "matpower").unwrap();
2512 assert!(
2513 parsed.render_diagnostics().is_empty(),
2514 "{:?}",
2515 parsed.render_diagnostics()
2516 );
2517 let normalized = parsed
2518 .network
2519 .to_normalized_with_options(&crate::NormalizeOptions::default())
2520 .unwrap();
2521 let absent: Vec<_> = normalized
2522 .diagnostics
2523 .iter()
2524 .filter(|d| d.code() == "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT")
2525 .collect();
2526 assert_eq!(absent.len(), 1, "{:?}", normalized.warnings);
2527 assert!(absent[0].message().contains("no cost data"), "{absent:?}");
2528 assert!(absent[0].message().contains("1 in-service"), "{absent:?}");
2529
2530 let costed = format!("{costless}mpc.gencost = [\n\t2\t0\t0\t3\t0.01\t40\t0;\n];\n");
2532 let parsed = parse_str(&costed, "matpower").unwrap();
2533 let normalized = parsed
2534 .network
2535 .to_normalized_with_options(&crate::NormalizeOptions::default())
2536 .unwrap();
2537 assert!(
2538 normalized
2539 .diagnostics
2540 .iter()
2541 .all(|d| d.code() != "CANONICALIZE.NORMALIZE.GEN_COST_ABSENT"),
2542 "{:?}",
2543 normalized.warnings
2544 );
2545 }
2546
2547 #[test]
2548 fn distribution_from_token_error_names_the_distribution_surface() {
2549 for token in ["dss", "pmd", "bmopf"] {
2550 let err = parse_str("anything", token).unwrap_err();
2551 assert!(
2552 err.to_string().contains("one module family"),
2553 "{token}: {err}"
2554 );
2555 }
2556 let err = parse_str("anything", "nonesuch").unwrap_err();
2558 assert!(err.to_string().contains("nonesuch"));
2559 }
2560
2561 #[test]
2562 fn byte_order_mark_is_retained_and_echoed() {
2563 let case = "\u{feff}function mpc = t\n\
2568 mpc.version = '2';\n\
2569 mpc.baseMVA = 100;\n\
2570 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2571 mpc.gen = [];\n\
2572 mpc.branch = [];\n";
2573 let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2574 let module = parse(source.with_format(parse_format_id("matpower").unwrap())).unwrap();
2575 assert_eq!(module.value().buses().len(), 1);
2576 assert!(
2577 module.diagnostics().is_empty(),
2578 "{:?}",
2579 module.diagnostics()
2580 );
2581 let echo = emit_text(&module, TargetFormat::Matpower).unwrap();
2582 assert_eq!(echo.text, case, "the echo reproduces the mark exactly");
2583 }
2584
2585 #[test]
2586 fn canonical_format_bypasses_same_format_matpower_echo() {
2587 let case = "function mpc = t\n\
2588 % a comment the canonical writer does not keep\n\
2589 mpc.version = '2';\n\
2590 mpc.baseMVA = 100;\n\
2591 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2592 mpc.gen = [];\n\
2593 mpc.branch = [];\n";
2594 let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2595 let module =
2596 parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap())).unwrap();
2597 assert_eq!(
2598 emit_text(&module, TargetFormat::Matpower).unwrap().text,
2599 case
2600 );
2601
2602 let net = module.into_value();
2603 let canonical = emit_value_text(&net, TargetFormat::Matpower).unwrap();
2604 assert_ne!(canonical.text, case);
2605 let reparsed = parse_str(&canonical.text, "matpower").unwrap();
2606 assert_eq!(reparsed.network.buses().len(), 1);
2607 }
2608
2609 #[test]
2610 fn bus_branch_formats_report_dropped_exchange_context_once_or_carry_it() {
2611 let case = "function mpc = t\n\
2612 mpc.version = '2';\n\
2613 mpc.baseMVA = 100;\n\
2614 mpc.bus = [1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2615 mpc.gen = [];\n\
2616 mpc.branch = [];\n";
2617 let mut net = parse_str(case, "matpower").unwrap().network;
2618 *net.detailed_connectivity_mut() =
2619 Some(std::sync::Arc::new(crate::DetailedConnectivity::default()));
2620 assert!(exchange_context_loss(&net, "PowerModels JSON").is_none());
2621
2622 net.case_metadata_mut().case_date = Some("2025-01-02T03:04:05Z".into());
2623 net.buses_mut()[0].uid = Some("source-bus".into());
2624 let mut detailed = crate::DetailedConnectivity::default();
2625 detailed.substations.push(crate::Substation {
2626 component: powerio_core::ComponentId::new("substation", "source-substation").unwrap(),
2627 country: None,
2628 operator: None,
2629 geographical_tags: Vec::new(),
2630 });
2631 *net.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2632
2633 let emitted = emit_value_text(&net, TargetFormat::PowerModelsJson).unwrap();
2634 let context = emitted
2635 .diagnostics
2636 .iter()
2637 .filter(|diagnostic| {
2638 diagnostic.code() == "EMIT.POWERMODELS.FIELD_DROPPED"
2639 && diagnostic.message().contains("grid exchange context")
2640 })
2641 .collect::<Vec<_>>();
2642 assert!(context.is_empty(), "{:?}", emitted.render_diagnostics());
2645 let reparsed = parse_str(&emitted.text, "powermodels-json")
2646 .unwrap()
2647 .network;
2648 assert_eq!(
2649 reparsed.case_metadata().case_date.as_deref(),
2650 Some("2025-01-02T03:04:05Z")
2651 );
2652 assert_eq!(reparsed.buses()[0].uid.as_deref(), Some("source-bus"));
2653 assert_eq!(
2654 reparsed.detailed_connectivity(),
2655 net.detailed_connectivity()
2656 );
2657
2658 let ravens = emit_value_text(&net, TargetFormat::RavensJson).unwrap();
2659 let context = ravens
2660 .diagnostics
2661 .iter()
2662 .filter(|diagnostic| {
2663 diagnostic.code() == "EMIT.RAVENS.FIELD_DROPPED"
2664 && diagnostic.message().contains("grid exchange context")
2665 })
2666 .collect::<Vec<_>>();
2667 assert_eq!(context.len(), 1, "{:?}", ravens.render_diagnostics());
2668 let message = context[0].message();
2669 assert!(message.contains("case_date"), "{message}");
2670 assert!(message.contains("detailed_connectivity"), "{message}");
2671 assert!(!message.contains("source-assigned"), "{message}");
2672 let reparsed = parse_str(&ravens.text, "ravens-json").unwrap().network;
2673 assert_eq!(reparsed.buses()[0].uid.as_deref(), Some("source-bus"));
2674
2675 *net.case_metadata_mut() = crate::network::CaseMetadata::default();
2676 *net.detailed_connectivity_mut() = None;
2677 let ravens = emit_value_text(&net, TargetFormat::RavensJson).unwrap();
2678 assert!(
2679 ravens
2680 .diagnostics
2681 .iter()
2682 .all(|diagnostic| !diagnostic.message().contains("grid exchange context")),
2683 "retained identities alone are not a loss: {:?}",
2684 ravens.render_diagnostics()
2685 );
2686 }
2687
2688 #[test]
2689 fn source_format_strings_round_trip_to_a_target() {
2690 for (sf, want) in [
2694 (SourceFormat::Matpower, TargetFormat::Matpower),
2695 (SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
2696 (SourceFormat::EgretJson, TargetFormat::EgretJson),
2697 (SourceFormat::Psse, TargetFormat::Psse { rev: 33 }),
2698 (SourceFormat::PowerWorld, TargetFormat::PowerWorld),
2699 (SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
2700 (SourceFormat::Pslf, TargetFormat::Pslf),
2701 (SourceFormat::Goc3Json, TargetFormat::Goc3Json),
2702 (SourceFormat::SurgeJson, TargetFormat::SurgeJson),
2703 (SourceFormat::RavensJson, TargetFormat::RavensJson),
2704 (SourceFormat::Ucte, TargetFormat::Ucte),
2705 (
2706 SourceFormat::DeepMindOpfDataJson,
2707 TargetFormat::DeepMindOpfDataJson,
2708 ),
2709 ] {
2710 let token = sf.name();
2711 assert_eq!(
2712 parse_target_format(token),
2713 Some(want),
2714 "source_format {token:?} did not round-trip"
2715 );
2716 }
2717 for sf in [
2720 SourceFormat::InMemory,
2721 SourceFormat::Normalized,
2722 SourceFormat::Gridfm,
2723 SourceFormat::PypsaCsv,
2724 SourceFormat::PowerWorldBinary,
2725 SourceFormat::IeeeCdf,
2726 ] {
2727 assert_eq!(parse_target_format(sf.name()), None);
2728 }
2729 }
2730
2731 #[test]
2732 fn unsupported_indexed_assignment_warning_preserves_public_literals_and_echo() {
2733 let case = "function mpc = t\n\
2734 mpc.version = '2';\n\
2735 mpc.baseMVA = 100;\n\
2736 mpc.bus = [1 3 12 4 0 0 1 1.0 0 345 1 1.1 0.9;];\n\
2737 mpc.gen = [];\n\
2738 mpc.branch = [];\n\
2739 mpc.bus(:, [3 4]) = mpc.bus(:, [3 4]) / unknown_scale;\n";
2740 let source = powerio_core::Source::from_memory("case.m", case.as_bytes().to_vec()).unwrap();
2741 let module = parse(source.with_format(powerio_core::FormatId::new("matpower").unwrap()))
2742 .expect("literal case parses with a diagnostic");
2743 assert!(
2744 module.diagnostics().iter().any(|d| {
2745 d.code() == "READ.MATPOWER.INDEXED_ASSIGNMENT"
2746 && d.severity() == powerio_core::DiagnosticSeverity::Warning
2747 }),
2748 "the warning must be present on the returned public module: {:?}",
2749 module.diagnostics()
2750 );
2751 let demand: f64 = module.value().loads().iter().map(|load| load.p).sum();
2752 assert!(
2753 (demand - 12.0).abs() < 1e-12,
2754 "unsupported conversion must not mutate literal values: {demand}"
2755 );
2756 assert_eq!(
2757 emit_text(&module, TargetFormat::Matpower).unwrap().text,
2758 case,
2759 "same-format emission must preserve the exact source bytes"
2760 );
2761 }
2762}