1pub const VERSION: &str = env!("CARGO_PKG_VERSION");
75
76pub const IR_SCHEMA_NAME: &str = "pio-ir";
78
79pub const IR_VERSION: u64 = 2;
95
96pub const IR_MIN_VERSION: u64 = 2;
101
102pub const IR_SCHEMA_ID: &str = "https://powerio.dev/schema/pio-ir/2/schema.json";
105
106use powerio_tx::format;
107pub use powerio_tx::{
108 Area, BalancedNetwork, Branch, BranchCharging, BranchCurrentRatings, BranchRatingSet,
109 BranchSolution, BranchSusceptanceFormula, Bus, BusId, BusType, Canvas, CoordinateSpace,
110 CoordsKind, DEFAULT_BASE_FREQUENCY, Detection, ElementKey, Extras, GenCaps, GenCost, Generator,
111 GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoMeta, GeoParsed, GeoTarget, Hvdc,
112 Impedance, IndexCore, IndexedNetwork, JSON_CLASSES, JsonClass, Load, LoadVoltageModel,
113 Location, PwdDisplay, PwdSubstation, Selector, Shunt, ShuntBlock, SolverParams, SourceFormat,
114 Storage, Switch, SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
115 TransformerControlMode, Winding, apply_substation_points, calc_series_admittance_of,
116 classify_json_bytes, classify_json_text, repair_values, to_geo_layer_from_pwd,
117 to_lonlat_from_pwd_mercator,
118};
119pub use powerio_tx::{geo, network, version};
124
125pub use powerio_core::diagnostic_codes;
126pub use powerio_core::{
129 ArtifactPath, ComponentId, Destination, Diagnostic, DiagnosticCode, DiagnosticId,
130 DiagnosticInfo, DiagnosticSeverity, DiagnosticStage, Digest, DigestAlgorithm, EmitResult,
131 EmittedOutput, Fidelity, FormatId, HistoryEntry, HistoryId, HistoryKind, MemoryArtifact,
132 OutputLayout, PioModule, Producer, Scenario, ScenarioId, ScenarioSet, Source, SourceBuffer,
133 SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan, StagedEdit, TimePoint,
134 TimeSeries,
135};
136
137pub use powerio_core::Error;
140pub type Result<T> = std::result::Result<T, powerio_core::Error>;
141
142pub use powerio_dist as dist;
146pub use powerio_dist::{
147 BmopfEmitOptions, BmopfSchemaVersion, ConductorMatrix, DistGeoMeta, DistGraphEdgeKind,
148 MulticonductorNetwork,
149};
150
151pub use powerio_prob::solution::{SocwrOpfDuals, SocwrOpfSolution, SocwrOpfValues};
152pub use powerio_prob::{
156 AcBusSpecification, AcOpfInstance, AcOpfSolution, AcPfInstance, AcPfSolution, AcScucInstance,
157 AcScucSolution, ActivePower, ActivePowerUnit, ApparentPower, ApparentPowerUnit,
158 BalancedCalculationInstance, CalculationUpdate, DcBusSpecification, DcOpfInstance,
159 DcOpfSolution, DcPfInstance, DcPfSolution, LoadAllocation, McAcOpfInstance, McAcOpfSolution,
160 McAcPfInstance, McAcPfSolution, NetworkUpdate, OperatingPointUpdate, ReactivePower,
161 ReactivePowerUnit, Termination, ThreeWindingTransformerTerminalActivePower,
162 ThreeWindingTransformerTerminalPower, UpdateChange, UpdateReport, UpdatedField,
163 apply_bus_load_active_power, apply_updates,
164};
165
166#[cfg(feature = "matrix")]
171pub use powerio_matrix as matrix;
172
173#[cfg(feature = "gridfm")]
174#[doc(hidden)]
175#[path = "gridfm.rs"]
176pub mod __gridfm;
177pub mod codes;
178mod formats;
179pub use formats::{FormatInfo, resolve_format};
180#[cfg(feature = "gridfm")]
181mod collect;
182pub mod dist_geo;
183#[cfg(feature = "gridfm")]
184pub use __gridfm::codes as gridfm_codes;
185mod stored;
186mod write;
187pub use write::emit;
188mod ir;
189#[cfg(feature = "schema")]
190pub use ir::generate_ir_schema;
191pub use ir::{deserialize, serialize, serialize_diagnostics};
192pub mod transform;
193pub use transform::{
194 apply_geo_layer, to_ac_opf_instance, to_ac_pf_instance, to_dc_opf_instance, to_dc_pf_instance,
195 to_mc_ac_opf_instance, to_mc_ac_pf_instance,
196};
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199enum Goc3DataFileKind {
200 Problem,
201 Solution,
202}
203
204#[derive(Default)]
205struct Goc3DataFiles {
206 problem: Option<SourceBuffer>,
207 solution: Option<SourceBuffer>,
208}
209
210impl Goc3DataFiles {
211 fn insert(&mut self, kind: Goc3DataFileKind, buffer: SourceBuffer) -> Result<()> {
212 let slot = match kind {
213 Goc3DataFileKind::Problem => &mut self.problem,
214 Goc3DataFileKind::Solution => &mut self.solution,
215 };
216 if let Some(existing) = slot {
217 return Err(Error::new(
218 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
219 format!(
220 "GO Challenge 3 source contains both `{}` and `{}` as {} data files",
221 existing.name(),
222 buffer.name(),
223 match kind {
224 Goc3DataFileKind::Problem => "problem",
225 Goc3DataFileKind::Solution => "solution",
226 }
227 ),
228 ));
229 }
230 *slot = Some(buffer);
231 Ok(())
232 }
233}
234
235#[derive(Default, serde::Deserialize)]
237struct Goc3Roots {
238 #[serde(default)]
239 network: Option<serde::de::IgnoredAny>,
240 #[serde(default)]
241 time_series_input: Option<serde::de::IgnoredAny>,
242 #[serde(default)]
243 reliability: Option<serde::de::IgnoredAny>,
244 #[serde(default)]
245 time_series_output: Option<serde::de::IgnoredAny>,
246}
247
248impl Goc3Roots {
249 fn is_problem(&self) -> bool {
250 self.network.is_some() && self.time_series_input.is_some() && self.reliability.is_some()
251 }
252
253 fn is_solution(&self) -> bool {
254 self.time_series_output.is_some()
255 }
256}
257
258fn goc3_roots(buffer: &SourceBuffer) -> Result<Option<Goc3Roots>> {
261 match serde_json::from_slice::<Goc3Roots>(buffer.content_bytes()) {
262 Ok(roots) => Ok(Some(roots)),
263 Err(error) if error.classify() == serde_json::error::Category::Data => Ok(None),
264 Err(error) => Err(Error::new(
265 &powerio_tx::diagnostics::codes::PARSE_GOC3_MALFORMED,
266 format!("{}: {error}", buffer.name()),
267 )),
268 }
269}
270
271fn goc3_file_kind(buffer: &SourceBuffer) -> Result<Option<Goc3DataFileKind>> {
272 let Some(roots) = goc3_roots(buffer)? else {
273 return Ok(None);
274 };
275 match (roots.is_problem(), roots.is_solution()) {
276 (true, false) => Ok(Some(Goc3DataFileKind::Problem)),
277 (false, true) => Ok(Some(Goc3DataFileKind::Solution)),
278 (false, false) => Ok(None),
279 (true, true) => Err(Error::new(
280 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
281 format!(
282 "{} contains both the GO Challenge 3 problem and solution roots",
283 buffer.name()
284 ),
285 )),
286 }
287}
288
289fn goc3_data_files(source: &Source) -> Result<Goc3DataFiles> {
290 let mut buffers = if source.is_directory() {
291 let mut buffers = Vec::new();
292 for name in source.entry_names()? {
293 if std::path::Path::new(name.as_str())
294 .extension()
295 .and_then(|extension| extension.to_str())
296 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
297 {
298 buffers.push(source.buffer(&name)?);
299 }
300 }
301 buffers
302 } else {
303 let mut buffers = vec![source.primary_buffer()?];
304 if let Ok(names) = source.entry_names() {
307 for name in names {
308 buffers.push(source.root_buffer(name.as_str())?);
309 }
310 }
311 buffers
312 };
313 buffers.sort_by(|left, right| left.name().cmp(right.name()));
314
315 let mut files = Goc3DataFiles::default();
316 for buffer in buffers {
317 if let Some(kind) = goc3_file_kind(&buffer)? {
318 files.insert(kind, buffer)?;
319 }
320 }
321 Ok(files)
322}
323
324fn directory_has_goc3_data(source: &Source) -> bool {
325 source.entry_names().is_ok_and(|names| {
326 names.into_iter().any(|name| {
327 std::path::Path::new(name.as_str())
328 .extension()
329 .and_then(|extension| extension.to_str())
330 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
331 && source.buffer(&name).is_ok_and(|buffer| {
332 goc3_roots(&buffer).is_ok_and(|roots| {
333 roots.is_some_and(|roots| roots.is_solution() || roots.is_problem())
334 })
335 })
336 })
337 })
338}
339
340pub fn to_geo_layer_from_aux_text(text: &str) -> Result<GeoLayer> {
349 let aux = powerio_tx::format::powerworld::aux_sections(text)
350 .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
351 Ok(powerio_tx::to_geo_layer_from_aux_substations(&aux))
352}
353
354pub use powerio_prob::OperatingPoint;
357mod value;
358pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
359
360#[derive(Clone, Debug, Default)]
363#[non_exhaustive]
364pub struct ParseOptions {
365 pub format: Option<powerio_core::FormatId>,
368 pub acquisition_root: Option<std::path::PathBuf>,
371}
372
373impl ParseOptions {
374 pub fn format(mut self, format: &str) -> std::result::Result<Self, powerio_core::Error> {
379 self.format = Some(powerio_core::FormatId::new(format)?);
380 Ok(self)
381 }
382
383 #[must_use]
385 pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
386 self.format = Some(format);
387 self
388 }
389
390 #[must_use]
392 pub fn acquisition_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
393 self.acquisition_root = Some(root.into());
394 self
395 }
396}
397
398pub fn parse(
436 input: impl powerio_core::IntoSource,
437) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
438 parse_with_options(input, &ParseOptions::default())
439}
440
441pub fn parse_with_options(
449 input: impl powerio_core::IntoSource,
450 options: &ParseOptions,
451) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
452 let mut source = input.into_source()?;
453 if let Some(root) = &options.acquisition_root {
454 source = source.with_acquisition_root(root.clone())?;
455 }
456 if let Some(format) = &options.format {
457 source = source.with_format(format.clone());
458 }
459 match routed_family(&source)? {
460 RoutedFamily::Goc3 => parse_goc3(source),
461 RoutedFamily::OpfData => powerio_prob::__internal::__decode_opfdata_solution(source)
462 .map(|module| module.map_value(PioValue::from)),
463 RoutedFamily::Distribution(detected) => {
464 let source = match (source.format(), detected) {
465 (None, Some(format)) => {
466 source.with_format(powerio_core::FormatId::new(format.name())?)
467 }
468 _ => source,
469 };
470 powerio_dist::parse(source).map(|module| module.map_value(PioValue::from))
471 }
472 RoutedFamily::PypsaDirectory => parse_pypsa(source),
473 #[cfg(feature = "gridfm")]
474 RoutedFamily::Gridfm => parse_gridfm(source),
475 RoutedFamily::Egret => parse_egret(source),
476 RoutedFamily::Geo => parse_geo_layer(source),
477 RoutedFamily::Balanced(json_class) => format::parse_with_json_class(source, json_class)
478 .map(|module| module.map_value(PioValue::from)),
479 }
480}
481
482fn parse_goc3(
488 source: powerio_core::Source,
489) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
490 let source = source.with_format(powerio_core::FormatId::new("goc3-json")?);
491 let files = match goc3_data_files(&source) {
492 Ok(files) => files,
493 Err(error) => return Err(error.with_source(source)),
494 };
495 let Some(problem) = files.problem else {
496 let message = if files.solution.is_some() {
497 "a GO Challenge 3 solution file requires the matching problem file in the same source"
498 } else {
499 "the source contains neither a GO Challenge 3 problem file nor a solution file"
500 };
501 return Err(Error::new(
502 &powerio_tx::diagnostics::codes::READ_GOC3_PROBLEM_REQUIRED,
503 message,
504 )
505 .with_source(source));
506 };
507
508 let (instance, diagnostics) =
509 match powerio_prob::__internal::__parse_goc3_problem_buffer(&problem) {
510 Ok(parsed) => parsed,
511 Err(error) => return Err(error.with_source(source)),
512 };
513 let value = match files.solution {
514 Some(solution) => {
515 let solution = match powerio_prob::__internal::__parse_goc3_output_buffer(
516 std::sync::Arc::new(instance),
517 &solution,
518 ) {
519 Ok(solution) => solution,
520 Err(error) => return Err(error.with_source(source)),
521 };
522 PioValue::from(solution)
523 }
524 None => PioValue::from(instance),
525 };
526 powerio_core::PioModule::parsed(value, source, diagnostics)
527}
528
529fn parse_geo_layer(
534 source: powerio_core::Source,
535) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
536 let name = source.name().to_owned();
537 let declared = source.format().map(|format| format.as_str().to_owned());
538 let is_display = declared.as_deref().is_some_and(is_pwd_display_token)
539 || std::path::Path::new(&name)
540 .extension()
541 .and_then(|extension| extension.to_str())
542 .is_some_and(|extension| extension.eq_ignore_ascii_case("pwd"));
543
544 let buffer = match source.primary_buffer() {
545 Ok(buffer) => buffer,
546 Err(error) => return Err(error.with_source(source)),
547 };
548 let (layer, diagnostics) = if is_display {
549 match powerio_tx::format::powerworld::__parse_pwd_display(buffer.content_bytes()) {
550 Ok(display) => (powerio_tx::geo::to_geo_layer_from_pwd(&display), Vec::new()),
551 Err(error) => {
552 return Err(Error::new(error.code(), error.to_string())
553 .with_cause(error)
554 .with_source(source));
555 }
556 }
557 } else {
558 let text = match std::str::from_utf8(buffer.content_bytes()) {
559 Ok(text) => text,
560 Err(cause) => {
561 return Err(Error::new(
562 &powerio_tx::diagnostics::codes::READ_GEO_NOT_TEXT,
563 format!("a geographic layer document is not valid UTF-8: {cause}"),
564 )
565 .with_source(source));
566 }
567 };
568 match powerio_tx::geo::GeoLayer::parse(
569 text,
570 std::path::Path::new(&name)
571 .file_name()
572 .and_then(|name| name.to_str()),
573 ) {
574 Ok(parsed) => (parsed.layer, parsed.diagnostics),
575 Err(error) => {
576 return Err(Error::new(error.code(), error.to_string())
577 .with_cause(error)
578 .with_source(source));
579 }
580 }
581 };
582 let source = match declared {
583 Some(_) => source,
584 None => source.with_format(powerio_core::FormatId::new(if is_display {
585 "powerworld-pwd"
586 } else {
587 "geo-json"
588 })?),
589 };
590 powerio_core::PioModule::parsed(PioValue::from(layer), source, diagnostics)
591}
592
593fn parse_pypsa(
598 source: powerio_core::Source,
599) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
600 if !source.is_directory() {
601 return format::parse(source).map(|module| module.map_value(PioValue::from));
603 }
604 let source = match source.format() {
608 Some(_) => source,
609 None => source.with_format(powerio_core::FormatId::new("pypsa-csv")?),
610 };
611 let axis = match format::__pypsa_axis(&source) {
612 Ok(axis) => axis,
613 Err(error) => {
614 let core = powerio_core::Error::new(error.code(), error.to_string());
615 return Err(core.with_source(source));
616 }
617 };
618 match axis {
619 format::PypsaAxis::SingleSnapshot => {
620 format::parse(source).map(|module| module.map_value(PioValue::from))
621 }
622 format::PypsaAxis::Series => {
623 match powerio_prob::__internal::__decode_pypsa_sequence(&source) {
624 Ok((sequence, diagnostics)) => {
625 let value = match sequence {
626 powerio_prob::__internal::PypsaSequence::Networks(series) => {
627 PioValue::from(series)
628 }
629 powerio_prob::__internal::PypsaSequence::OperatingPoints(points) => {
630 PioValue::from(points)
631 }
632 };
633 powerio_core::PioModule::parsed(value, source, diagnostics)
634 }
635 Err(error) => Err(error.with_source(source)),
636 }
637 }
638 }
639}
640
641#[cfg(feature = "gridfm")]
644fn parse_gridfm(
645 source: powerio_core::Source,
646) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
647 if !source.is_directory() {
648 return format::parse(source).map(|module| module.map_value(PioValue::from));
650 }
651 let source = match source.format() {
652 Some(_) => source,
653 None => source.with_format(powerio_core::FormatId::new("gridfm")?),
654 };
655 match __gridfm::parse_gridfm_source(&source) {
656 Ok((set, diagnostics)) => {
657 powerio_core::PioModule::parsed(PioValue::from(set), source, diagnostics)
658 }
659 Err(error) => Err(error.with_source(source)),
660 }
661}
662
663fn parse_egret(
667 source: powerio_core::Source,
668) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
669 let declares_series = {
670 let buffer = source.primary_buffer()?;
671 std::str::from_utf8(buffer.content_bytes()).is_ok_and(format::__egret_declares_time_series)
672 };
673 if !declares_series {
674 return format::parse(source).map(|module| module.map_value(PioValue::from));
675 }
676 let parsed = {
677 let buffer = source.primary_buffer()?;
678 let stem = std::path::Path::new(source.name())
679 .file_stem()
680 .and_then(|stem| stem.to_str())
681 .map(str::to_owned);
682 match std::str::from_utf8(buffer.content_bytes()) {
683 Ok(text) => format::__parse_egret_time_series(text, stem.as_deref())
684 .map_err(|error| powerio_core::Error::new(error.code(), error.to_string())),
685 Err(error) => {
686 let cause = powerio_tx::Error::FormatRead {
687 format: "case text",
688 message: format!("not valid UTF-8: {error}"),
689 };
690 Err(powerio_core::Error::new(cause.code(), cause.to_string()))
691 }
692 }
693 };
694 match parsed {
695 Ok(series) => powerio_core::PioModule::parsed(PioValue::from(series), source, Vec::new()),
696 Err(error) => Err(error.with_source(source)),
697 }
698}
699
700enum RoutedFamily {
707 Balanced(Option<format::routing::JsonClass>),
708 Distribution(Option<format::routing::DistributionFormat>),
709 Goc3,
710 OpfData,
711 PypsaDirectory,
712 Egret,
713 Geo,
717 #[cfg(feature = "gridfm")]
718 Gridfm,
719}
720
721fn routed_family(
722 source: &powerio_core::Source,
723) -> std::result::Result<RoutedFamily, powerio_core::Error> {
724 if let Some(declared) = source.format() {
725 return Ok(family_of_token(declared.as_str()));
726 }
727 if source.is_directory() {
728 if directory_has_goc3_data(source) {
732 return Ok(RoutedFamily::Goc3);
733 }
734 let marker = powerio_core::ArtifactPath::new("network.csv")
738 .expect("static name is a valid artifact path");
739 if source.buffer(&marker).is_ok() {
740 return Ok(RoutedFamily::PypsaDirectory);
741 }
742 #[cfg(feature = "gridfm")]
743 if let Ok(entries) = source.entry_names()
744 && entries.iter().any(|entry| {
745 entry.as_str().ends_with("bus_data.parquet")
746 && matches!(entry.as_str().matches('/').count(), 0..=2)
747 })
748 {
749 return Ok(RoutedFamily::Gridfm);
750 }
751 return Ok(RoutedFamily::Balanced(None));
752 }
753 let extension = std::path::Path::new(source.name())
754 .extension()
755 .and_then(|extension| extension.to_str())
756 .unwrap_or_default()
757 .to_ascii_lowercase();
758 if has_geo_layer_extension(source.name()) {
759 return Ok(RoutedFamily::Geo);
760 }
761 match extension.as_str() {
762 "dss" => Ok(RoutedFamily::Distribution(Some(
763 format::routing::DistributionFormat::Dss,
764 ))),
765 "json" => json_family(source),
766 "pwd" | "geojson" => Ok(RoutedFamily::Geo),
771 "m" | "raw" | "aux" | "epc" | "pwb" | "uct" => Ok(RoutedFamily::Balanced(None)),
772 _ => {
773 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
774 std::str::from_utf8(buffer.content_bytes()).is_ok_and(|text| {
775 text.trim_start_matches('\u{feff}')
779 .trim_start()
780 .starts_with(['{', '['])
781 })
782 });
783 if jsonish {
784 json_family(source)
785 } else {
786 Ok(RoutedFamily::Balanced(None))
787 }
788 }
789 }
790}
791
792fn has_geo_layer_extension(name: &str) -> bool {
799 let name = name.to_ascii_lowercase();
800 let extension = powerio_tx::geo::GEO_LAYER_EXTENSION;
801 name == extension
802 || name
803 .strip_suffix(extension)
804 .is_some_and(|stem| stem.ends_with(['.', '_', '-', '/', '\\']))
805}
806
807pub(crate) fn is_geo_layer_token(token: &str) -> bool {
809 matches!(
810 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
811 "geojson" | "geo" | "geolayer"
812 )
813}
814
815pub(crate) fn is_pwd_display_token(token: &str) -> bool {
818 matches!(
819 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
820 "pwd" | "powerworldpwd" | "powerworlddisplay"
821 )
822}
823
824fn is_geo_token(token: &str) -> bool {
826 is_geo_layer_token(token) || is_pwd_display_token(token)
827}
828
829fn json_family(
831 source: &powerio_core::Source,
832) -> std::result::Result<RoutedFamily, powerio_core::Error> {
833 use format::routing::{Detection, JsonClass, SourceFormat, TransmissionFormat};
834
835 let buffer = source.primary_buffer()?;
836 let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
840 return Ok(RoutedFamily::Balanced(None));
841 };
842 let class = format::routing::classify_json_text(text);
843 match class {
844 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
845 TransmissionFormat::Goc3Json,
846 ))) => Ok(RoutedFamily::Goc3),
847 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
848 TransmissionFormat::DeepMindOpfDataJson,
849 ))) => Ok(RoutedFamily::OpfData),
850 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
851 TransmissionFormat::EgretJson,
852 ))) => Ok(RoutedFamily::Egret),
853 JsonClass::Case(Detection::Known(SourceFormat::Distribution(format))) => {
854 Ok(RoutedFamily::Distribution(Some(format)))
855 }
856 JsonClass::Module => Err(powerio_core::Error::new(
857 &codes::REQUEST_PARSE_POWERIO_IR,
858 "PowerIO IR is not a grid exchange format; call deserialize(source)",
859 )),
860 JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
864 Ok(RoutedFamily::Balanced(Some(class)))
865 }
866 }
867}
868
869fn family_of_token(token: &str) -> RoutedFamily {
872 use format::TargetFormat;
873
874 if is_geo_token(token) {
875 return RoutedFamily::Geo;
876 }
877
878 if powerio_dist::parse_dist_target_format(token).is_some() {
879 return RoutedFamily::Distribution(None);
880 }
881 if format::is_pypsa_csv_name(token) {
882 return RoutedFamily::PypsaDirectory;
883 }
884 #[cfg(feature = "gridfm")]
885 if token.eq_ignore_ascii_case("gridfm") {
886 return RoutedFamily::Gridfm;
887 }
888 match format::parse_target_format(token) {
889 Some(TargetFormat::Goc3Json) => RoutedFamily::Goc3,
890 Some(TargetFormat::DeepMindOpfDataJson) => RoutedFamily::OpfData,
891 Some(TargetFormat::EgretJson) => RoutedFamily::Egret,
892 _ => RoutedFamily::Balanced(None),
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 fn memory(name: &str, text: &str) -> powerio_core::Source {
901 powerio_core::Source::from_memory(name, text.as_bytes().to_vec()).expect("memory source")
902 }
903
904 fn parse(
905 source: powerio_core::Source,
906 ) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
907 super::parse(source)
908 }
909
910 fn options(format: Option<&str>) -> ParseOptions {
911 match format {
912 Some(format) => ParseOptions::default().format(format).unwrap(),
913 None => ParseOptions::default(),
914 }
915 }
916
917 fn assert_value_type(module: &powerio_core::PioModule<PioValue>, expected: &str) {
918 assert_eq!(module.value().type_name(), expected);
919 }
920
921 #[test]
922 fn a_matpower_source_parses_to_a_balanced_network() {
923 let case = "function mpc = case\n\
924 mpc.version = '2';\n\
925 mpc.baseMVA = 100;\n\
926 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
927 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
928 mpc.branch = [];\n";
929 let module = parse(
930 memory("case.m", case).with_format(powerio_core::FormatId::new("matpower").unwrap()),
931 )
932 .expect("matpower parses");
933 assert_value_type(&module, "powerio.BalancedNetwork");
934 }
935
936 #[test]
937 fn memory_parse_retains_its_name_and_optional_format() {
938 let case = "function mpc = inline\n\
939 mpc.version = '2';\n\
940 mpc.baseMVA = 100;\n\
941 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
942 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
943 mpc.branch = [];\n";
944
945 let detected = super::parse(memory("inline-case.m", case)).expect("name detects MATPOWER");
946 assert_eq!(detected.source().unwrap().name(), "inline-case.m");
947 assert_eq!(
948 detected.source().unwrap().format().map(FormatId::as_str),
949 Some("matpower")
950 );
951
952 let declared = super::parse_with_options(
953 memory("consumer-input", case),
954 &ParseOptions::default().format("matpower").unwrap(),
955 )
956 .expect("declared MATPOWER");
957 let source = declared.source().expect("source retained");
958 assert_eq!(source.name(), "consumer-input");
959 assert_eq!(source.format().map(FormatId::as_str), Some("matpower"));
960 }
961
962 #[test]
963 fn universal_parse_reads_declared_iso_8859_1_xiidm_and_retains_exact_bytes() {
964 let text = r#"<?xml version="1.0" encoding="ISO-8859-1"?>
965<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_17" id="case" caseDate="2026-01-01T00:00:00Z" forecastDistance="0" sourceFormat="Réseau PowSybl" minimumValidationLevel="STEADY_STATE_HYPOTHESIS">
966 <iidm:voltageLevel id="VL" nominalV="225" topologyKind="BUS_BREAKER">
967 <iidm:busBreakerTopology><iidm:bus id="B" v="225" angle="0"/></iidm:busBreakerTopology>
968 <iidm:generator id="G" energySource="OTHER" minP="0" maxP="100" voltageRegulatorOn="true" targetP="50" targetV="225" bus="B" connectableBus="B"><iidm:minMaxReactiveLimits minQ="-20" maxQ="20"/></iidm:generator>
969 </iidm:voltageLevel>
970</iidm:network>"#;
971 let bytes: Vec<u8> = text
972 .chars()
973 .map(|value| u8::try_from(u32::from(value)).expect("fixture is ISO-8859-1"))
974 .collect();
975 assert!(std::str::from_utf8(&bytes).is_err());
976
977 for (name, format) in [
978 ("case.xiidm", None),
979 ("case.xml", None),
980 ("memory", Some("xiidm")),
981 ] {
982 let source = Source::from_memory(name, bytes.clone()).unwrap();
983 let module = super::parse_with_options(source, &options(format)).unwrap();
984 let PioValue::BalancedNetwork(network) = &module.value() else {
985 panic!(
986 "expected BalancedNetwork, got {}",
987 module.value().type_name()
988 );
989 };
990 assert_eq!(
991 network.case_metadata().source_model_format.as_deref(),
992 Some("Réseau PowSybl")
993 );
994 let retained = module.source().unwrap();
995 assert_eq!(retained.format().map(FormatId::as_str), Some("xiidm"));
996 assert_eq!(retained.primary_buffer().unwrap().bytes(), bytes);
997
998 let emitted =
999 emit(&module, "xiidm", Destination::memory("copy.xiidm").unwrap()).unwrap();
1000 assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1001 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1002 panic!("memory destination returned a path output");
1003 };
1004 assert_eq!(artifacts.len(), 1);
1005 assert_eq!(artifacts[0].bytes(), bytes);
1006 }
1007 }
1008
1009 #[test]
1010 fn a_dss_source_parses_to_a_multiconductor_network() {
1011 let module = parse(memory(
1012 "feeder.dss",
1013 "New Circuit.c basekv=12.47 bus1=src\n",
1014 ))
1015 .expect("dss parses");
1016 let PioValue::MulticonductorNetwork(network) = &module.value() else {
1017 panic!(
1018 "expected multiconductor network, got {}",
1019 module.value().type_name()
1020 );
1021 };
1022 assert_eq!(network.name().as_deref(), Some("c"));
1023 }
1024
1025 #[test]
1026 fn a_declared_distribution_format_routes_without_an_extension() {
1027 let module = parse(
1028 memory("<memory>", "New Circuit.c basekv=12.47 bus1=src\n")
1029 .with_format(powerio_core::FormatId::new("dss").unwrap()),
1030 )
1031 .expect("declared dss parses");
1032 assert_value_type(&module, "powerio.MulticonductorNetwork");
1033 }
1034
1035 #[test]
1036 fn json_routes_by_top_level_markers() {
1037 let module = parse(memory(
1039 "feeder.json",
1040 r#"{"data_model": "ENGINEERING", "bus": {}}"#,
1041 ))
1042 .expect("pmd parses");
1043 assert_value_type(&module, "powerio.MulticonductorNetwork");
1044 }
1045
1046 #[test]
1047 fn a_bare_network_object_is_not_powerio_ir_or_a_case_format() {
1048 let error = parse(memory(
1049 "net.json",
1050 r#"{"name":"network","base_mva":100.0,"buses":[],"branches":[]}"#,
1051 ))
1052 .expect_err("an unmarked network object must not parse");
1053 assert!(error.to_string().contains("cannot infer JSON format"));
1054 }
1055
1056 #[test]
1057 fn the_error_path_retains_the_source() {
1058 let error = parse(memory("case.m", "not matpower at all")).expect_err("malformed");
1059 assert!(error.retained_source().is_some());
1060 }
1061
1062 fn fixture(path: &str) -> String {
1063 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1064 std::fs::read_to_string(root.join(path)).unwrap()
1065 }
1066
1067 #[test]
1068 fn goc3_parses_to_an_scuc_instance() {
1069 let text = fixture("../powerio-prob/tests/data/goc3_small.json");
1072 let module = parse(memory("goc3_small.json", &text)).expect("goc3 parses");
1073 assert_value_type(&module, "powerio.AcScucInstance");
1074 assert!(module.source().is_some());
1075 let PioValue::AcScucInstance(instance) = &module.value() else {
1076 unreachable!();
1077 };
1078 assert_eq!(instance.network().buses().len(), 2);
1079 }
1080
1081 #[test]
1082 fn goc3_problem_and_solution_parse_with_the_one_public_operation() {
1083 let problem = fixture("../tests/data/goc3/goc3_small.json");
1084 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1085 let source = powerio_core::Source::from_memory("problem.json", problem.into_bytes())
1086 .unwrap()
1087 .with_named_buffer("solution.json", solution.into_bytes())
1088 .unwrap();
1089 let module = parse(source).expect("problem and solution parse together");
1090 let PioValue::AcScucSolution(solution) = &module.value() else {
1091 panic!(
1092 "expected AC SCUC solution, got {}",
1093 module.value().type_name()
1094 );
1095 };
1096 assert_eq!(solution.instance().network().buses().len(), 2);
1097 assert_eq!(
1098 solution.network_outputs().shunt_step,
1099 vec![vec![1], vec![2]]
1100 );
1101 assert_eq!(module.sources().len(), 2);
1102
1103 let emitted = emit(
1104 &module,
1105 "goc3-json",
1106 Destination::memory("solution.json").unwrap(),
1107 )
1108 .expect("solution emits as official GOC3 output");
1109 let EmittedOutput::Memory { artifacts } = emitted.output() else {
1110 unreachable!();
1111 };
1112 assert_eq!(artifacts.len(), 1);
1113 let document: serde_json::Value = serde_json::from_slice(artifacts[0].bytes()).unwrap();
1114 assert!(document.get("time_series_output").is_some());
1115 assert!(document.get("network").is_none());
1116 }
1117
1118 #[test]
1119 fn goc3_solution_alone_names_the_missing_problem() {
1120 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1121 let error = parse(memory("solution.json", &solution))
1122 .expect_err("a solution without its problem is incomplete");
1123 assert!(error.to_string().contains("matching problem file"));
1124 assert!(error.retained_source().is_some());
1125 }
1126
1127 #[test]
1128 fn opfdata_parses_to_an_ac_opf_solution() {
1129 let text = fixture("../tests/data/opfdataset/example_0.json");
1130 let module = parse(memory("example_0.json", &text)).expect("opfdata parses");
1131 let PioValue::AcOpfSolution(solution) = &module.value() else {
1132 panic!(
1133 "expected AC OPF solution, got {}",
1134 module.value().type_name()
1135 );
1136 };
1137 assert_eq!(
1138 module
1139 .sources()
1140 .first()
1141 .and_then(|source| source.format())
1142 .map(powerio_core::FormatId::as_str),
1143 Some("opfdata-json")
1144 );
1145 assert_eq!(
1146 *solution.termination(),
1147 powerio_prob::Termination::NotReported
1148 );
1149 assert!((solution.objective() - 2_265.953_939_003_096).abs() < 1e-9);
1150
1151 let instance = solution.instance();
1152 assert_eq!(instance.network().buses().len(), 14);
1153 assert_eq!(instance.network().generators().len(), 5);
1154 let initial = instance.initial_point().expect("OPFData includes initials");
1155 let generator_id = instance.network().generators()[0]
1156 .uid
1157 .as_deref()
1158 .expect("parsed generators have stable identities");
1159 assert!((initial.generator_active_power(generator_id).unwrap() - 170.0).abs() < 1e-9);
1160 assert!((initial.generator_voltage_setpoint(generator_id).unwrap() - 1.0).abs() < 1e-12);
1161 assert!(solution.residuals().max_active_power_mismatch.unwrap() < 1.0);
1162 assert!(solution.residuals().max_reactive_power_mismatch.unwrap() < 1.0);
1163 }
1164
1165 #[test]
1166 fn malformed_opfdata_uses_the_universal_parse_error_path() {
1167 let error = parse(
1168 memory("broken.json", "{\"grid\": {}}")
1169 .with_format(powerio_core::FormatId::new("opfdata-json").unwrap()),
1170 )
1171 .expect_err("malformed OPFData");
1172 assert!(error.retained_source().is_some());
1173 }
1174
1175 const BMOPF_TINY: &str = r#"{
1176 "bus": {"a": {"terminal_names": ["1", "2", "3", "n"],
1177 "perfectly_grounded_terminals": ["n"]}},
1178 "voltage_source": {"s": {"bus": "a", "terminal_map": ["1", "2", "3"],
1179 "v_magnitude": [240.0, 240.0, 240.0], "v_angle": [0.0, -2.0944, 2.0944]}}
1180 }"#;
1181
1182 #[test]
1183 fn bmopf_parses_to_a_multiconductor_network() {
1184 let module = parse(memory("feeder.json", BMOPF_TINY)).expect("sniffed bmopf parses");
1187 assert_value_type(&module, "powerio.MulticonductorNetwork");
1188
1189 let module = parse(
1190 memory("<memory>", BMOPF_TINY)
1191 .with_format(powerio_core::FormatId::new("bmopf-json").unwrap()),
1192 )
1193 .expect("declared bmopf parses");
1194 assert_value_type(&module, "powerio.MulticonductorNetwork");
1195 }
1196
1197 #[test]
1198 fn nameless_json_text_routes_by_content() {
1199 let goc3 = fixture("../powerio-prob/tests/data/goc3_small.json");
1203 let module = parse(memory("<memory>", &goc3)).expect("nameless goc3 parses");
1204 assert_value_type(&module, "powerio.AcScucInstance");
1205
1206 let module = parse(memory("<memory>", BMOPF_TINY)).expect("nameless bmopf parses");
1207 assert_value_type(&module, "powerio.MulticonductorNetwork");
1208 }
1209
1210 #[test]
1211 fn a_declared_problem_format_that_fails_retains_the_source() {
1212 let error = parse(
1213 memory("broken.json", "{\"network\": {}}")
1214 .with_format(powerio_core::FormatId::new("goc3-json").unwrap()),
1215 )
1216 .expect_err("malformed goc3");
1217 assert!(error.retained_source().is_some());
1218 }
1219
1220 const PYPSA_STATIC: [(&str, &str); 4] = [
1221 ("network.csv", "name\nseq\n"),
1222 ("buses.csv", "name,v_nom\nB1,138.0\nB2,138.0\n"),
1223 ("loads.csv", "name,bus,p_set,q_set\nL1,B2,5.0,1.0\n"),
1224 (
1225 "generators.csv",
1226 "name,bus,control,p_nom,p_set\nG1,B1,Slack,100.0,12.0\n",
1227 ),
1228 ];
1229
1230 fn pypsa_folder(extra: &[(&str, &str)]) -> tempfile::TempDir {
1231 let temp = tempfile::tempdir().unwrap();
1232 for (name, content) in PYPSA_STATIC.iter().chain(extra) {
1233 std::fs::write(temp.path().join(name), content).unwrap();
1234 }
1235 temp
1236 }
1237
1238 #[test]
1239 fn a_pypsa_snapshot_parses_to_a_balanced_network() {
1240 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n")]);
1241 let module =
1242 parse(powerio_core::Source::open(dir.path()).unwrap()).expect("snapshot parses");
1243 assert_value_type(&module, "powerio.BalancedNetwork");
1244 }
1245
1246 #[test]
1247 fn a_pypsa_input_series_parses_to_a_network_time_series() {
1248 let dir = pypsa_folder(&[
1249 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1250 ("loads-p_set.csv", "snapshot,L1\nnow,10.0\nlater,20.0\n"),
1251 ]);
1252 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1253 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1254 assert!(module.source().is_some());
1255 let PioValue::TimeSeries(series) = &module.value() else {
1256 unreachable!();
1257 };
1258 assert_eq!(series.len(), 2);
1259 let PioValue::BalancedNetwork(later) = series.get(1).unwrap() else {
1260 unreachable!();
1261 };
1262 assert!((later.loads()[0].p - 20.0).abs() < 1e-12);
1263 }
1264
1265 #[test]
1266 fn a_pypsa_voltage_series_parses_to_operating_points() {
1267 let dir = pypsa_folder(&[
1268 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1269 (
1270 "buses-v_mag_pu.csv",
1271 "snapshot,B1,B2\nnow,1.0,0.99\nlater,1.0,0.97\n",
1272 ),
1273 (
1274 "buses-v_ang.csv",
1275 "snapshot,B1,B2\nnow,0.0,-0.017453292519943295\nlater,0.0,-0.03490658503988659\n",
1276 ),
1277 ]);
1278 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1279 assert_value_type(
1280 &module,
1281 "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>",
1282 );
1283 let PioValue::TimeSeries(series) = &module.value() else {
1284 unreachable!();
1285 };
1286 let PioValue::BalancedOperatingPoint(later) = series.get(1).unwrap() else {
1287 unreachable!();
1288 };
1289 assert!((later.bus_voltage_magnitude(powerio_tx::BusId(2)).unwrap() - 0.97).abs() < 1e-12);
1290 }
1291
1292 #[test]
1293 fn a_pypsa_axis_with_no_series_stays_a_network_time_series() {
1294 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n1,later\n")]);
1298 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("axis parses");
1299 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1300 }
1301
1302 const EGRET_SERIES: &str = r#"{
1303 "model_name": "uc2",
1304 "elements": {
1305 "bus": {"1": {"matpower_bustype": "ref", "base_kv": 138.0},
1306 "2": {"matpower_bustype": "PQ", "base_kv": 138.0}},
1307 "load": {"load_1": {"bus": "2",
1308 "p_load": {"data_type": "time_series", "values": [10.0, 20.0]},
1309 "q_load": 3.0}},
1310 "generator": {"1": {"bus": "1", "pg": 12.0, "qg": 0.0,
1311 "p_min": 0.0, "p_max": 50.0, "q_min": -10.0, "q_max": 10.0}},
1312 "branch": {"1": {"from_bus": "1", "to_bus": "2",
1313 "resistance": 0.01, "reactance": 0.1, "charging_susceptance": 0.0,
1314 "rating_long_term": 100.0, "rating_short_term": 100.0,
1315 "rating_emergency": 100.0, "transformer_phase_shift": 0.0}}
1316 },
1317 "system": {"baseMVA": 100.0, "time_keys": ["t1", "t2"]}
1318 }"#;
1319
1320 #[test]
1321 fn egret_time_keys_parse_to_a_network_time_series() {
1322 let module = parse(
1323 memory("uc2.json", EGRET_SERIES)
1324 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1325 )
1326 .expect("egret series parses");
1327 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1328 assert!(module.source().is_some());
1329
1330 let module = parse(memory("uc2.json", EGRET_SERIES)).expect("sniffed egret parses");
1332 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1333 }
1334
1335 #[cfg(feature = "gridfm")]
1336 #[test]
1337 fn powerio_ir_uses_deserialize_not_parse() {
1338 use powerio_tx::{Bus, BusId, BusType};
1339 let network = powerio_tx::BalancedNetwork::in_memory(
1340 "stored",
1341 100.0,
1342 vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1343 vec![],
1344 );
1345 let original = powerio_core::PioModule::new(PioValue::BalancedNetwork(network));
1346 let emitted = serialize(&original, Destination::memory("case.pio.json").unwrap())
1347 .expect("module serializes");
1348 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1349 unreachable!();
1350 };
1351 let module = deserialize(
1352 Source::from_memory("case.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1353 )
1354 .expect("module deserializes");
1355 assert_value_type(&module, "powerio.BalancedNetwork");
1356 assert!(module.source().is_some());
1357 }
1358
1359 #[cfg(feature = "gridfm")]
1360 #[test]
1361 fn a_gridfm_dataset_parses_to_a_scenario_set() {
1362 let case = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
1365 let base = powerio_tx::parse(powerio_core::Source::open(case).unwrap())
1366 .expect("case9 parses")
1367 .into_value();
1368 let mut varied = base.clone();
1369 varied.loads_mut()[0].p += 5.0;
1370 let out = tempfile::tempdir().unwrap();
1371 let snapshots = [
1372 powerio_matrix::GridfmSnapshot::new(&base, 0),
1373 powerio_matrix::GridfmSnapshot::new(&varied, 1),
1374 ];
1375 powerio_matrix::emit_gridfm_batch(
1376 &snapshots,
1377 out.path(),
1378 &powerio_matrix::GridfmOptions::default(),
1379 )
1380 .expect("dataset writes");
1381
1382 let module =
1383 parse(powerio_core::Source::open(out.path()).unwrap()).expect("dataset parses");
1384 assert_value_type(&module, "powerio.ScenarioSet<powerio.BalancedNetwork>");
1385 assert!(module.source().is_some());
1386 let PioValue::ScenarioSet(set) = &module.value() else {
1387 unreachable!();
1388 };
1389 assert_eq!(set.len(), 2);
1390 assert!(set.get("0").is_some());
1391 assert!(set.get("1").is_some());
1392 }
1393
1394 #[test]
1395 fn an_unrecognized_directory_is_refused_with_the_hub_wording() {
1396 let dir = tempfile::tempdir().unwrap();
1397 std::fs::write(dir.path().join("notes.txt"), "not a case").unwrap();
1398 let error =
1399 parse(powerio_core::Source::open(dir.path()).unwrap()).expect_err("refused directory");
1400 assert!(error.to_string().contains("directory"), "{error}");
1401 }
1402
1403 #[test]
1404 fn a_scalar_egret_document_stays_a_balanced_network() {
1405 let scalar = EGRET_SERIES
1406 .replace(r#", "time_keys": ["t1", "t2"]"#, "")
1407 .replace(
1408 r#"{"data_type": "time_series", "values": [10.0, 20.0]}"#,
1409 "10.0",
1410 );
1411 let module = parse(
1412 memory("uc2.json", &scalar)
1413 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1414 )
1415 .expect("scalar egret parses");
1416 assert_value_type(&module, "powerio.BalancedNetwork");
1417 }
1418}