1pub const VERSION: &str = env!("CARGO_PKG_VERSION");
90
91pub const IR_SCHEMA_NAME: &str = "pio-ir";
93
94pub const IR_VERSION: u64 = 2;
110
111pub const IR_MIN_VERSION: u64 = 2;
116
117pub const IR_SCHEMA_ID: &str = "https://powerio.dev/schema/pio-ir/2/0.11.7/schema.json";
120
121pub use powerio_tx::dynamic::{
123 DynamicCase, DynamicRecord, DynamicStudy, DyrError, UnresolvedBusReference, parse_dyr,
124 parse_dyr_bytes,
125};
126
127use powerio_tx::format;
128pub use powerio_tx::{
129 Area, AutomaticOrder, AutomaticSpec, AutomaticTarget, BalancedNetwork, Branch, BranchCharging,
130 BranchCurrentRatings, BranchRatingSet, BranchRef, BranchSolution, BranchSusceptanceFormula,
131 Bus, BusId, BusSubset, BusType, Canvas, Change, ChangeOp, ChangeUnit, ContingencyAction,
132 ContingencyCase, ContingencyParsed, ContingencyResolution, ContingencySet, CoordinateSpace,
133 CoordsKind, DEFAULT_BASE_FREQUENCY, Detection, ElementKey, Expanded, Extras, GenCaps, GenCost,
134 Generator, GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoMeta, GeoParsed, GeoTarget,
135 Hvdc, Impedance, IndexCore, IndexedNetwork, InterfaceMember, JSON_CLASSES, JoinName, JsonClass,
136 Load, LoadVoltageModel, Location, MonitorScope, MonitorStatement, MonitoredParsed,
137 MonitoredResolution, MonitoredSet, PsseEquipmentIndex, PwdDisplay, PwdSubstation, ResolvedCase,
138 ResolvedComponent, ResolvedInterface, ResolvedVoltageScope, RetainedStatement, Selector,
139 SelectorGroup, SharedPsseEquipmentIndex, Shunt, ShuntBlock, SkipRule, SolverParams,
140 SourceFormat, Storage, Subsystem, SubsystemParsed, SubsystemSelector, SubsystemSet, Switch,
141 SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
142 TransformerControlMode, UnresolvedAction, UnresolvedMonitor, UnresolvedMonitorReason,
143 UnresolvedReason, Winding, apply_substation_points, calc_series_admittance_of,
144 classify_json_bytes, classify_json_text, repair_values, to_geo_layer_from_pwd,
145 to_lonlat_from_pwd_mercator,
146};
147pub use powerio_tx::{contingency, dynamic, geo, network, version};
152
153pub use powerio_core::diagnostic_codes;
154pub use powerio_core::{
157 ArtifactPath, ComponentId, Destination, Diagnostic, DiagnosticCode, DiagnosticId,
158 DiagnosticInfo, DiagnosticSeverity, DiagnosticStage, Digest, DigestAlgorithm, EmitResult,
159 EmittedOutput, Fidelity, FormatId, HistoryEntry, HistoryId, HistoryKind, MemoryArtifact,
160 OutputLayout, PioModule, Producer, Scenario, ScenarioId, ScenarioSet, Source, SourceBuffer,
161 SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan, StagedEdit, TimePoint,
162 TimeSeries,
163};
164
165pub use powerio_core::Error;
168pub type Result<T> = std::result::Result<T, powerio_core::Error>;
169
170pub use powerio_tnd as tnd;
172pub mod dist {
175 pub use powerio_dist::*;
176 #[cfg(feature = "sincal")]
177 pub use powerio_tnd::formats::sincal::{SincalReadOptions, parse_sincal};
178 pub fn parse(
179 source: powerio_core::Source,
180 ) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error>
181 {
182 #[cfg(feature = "sincal")]
183 if source.format().is_some_and(|f| {
184 crate::format::routing::parse_distribution_format(f.as_str())
185 == Some(crate::format::routing::DistributionFormat::SincalMulticonductor)
186 }) {
187 return parse_sincal(source, &SincalReadOptions::default());
188 }
189 powerio_dist::parse(source)
190 }
191}
192pub use powerio_dist::{
193 BmopfEmitOptions, BmopfSchemaVersion, ConductorMatrix, DistComponentLocation, DistGeoMeta,
194 DistGeographicPoint, DistGraphEdgeKind, DistLocationTarget, MulticonductorNetwork,
195 NeutralKronOptions, NeutralKronReport,
196};
197
198pub use powerio_prob::solution::{SocwrOpfDuals, SocwrOpfSolution, SocwrOpfValues};
199pub use powerio_prob::{
203 AcBusSpecification, AcOpfInstance, AcOpfSolution, AcOtsInstance, AcOtsOptions, AcOtsSolution,
204 AcPfInstance, AcPfSolution, AcScucInstance, AcScucSolution, ActivePower, ActivePowerUnit,
205 ApparentPower, ApparentPowerUnit, BalancedCalculationInstance, BranchCurrentLimit,
206 BranchSelection, BranchSwitchingCost, CalculationUpdate, CurrentLimitPolicy,
207 DcBusSpecification, DcOpfInstance, DcOpfSolution, DcPfInstance, DcPfSolution, IslandingPolicy,
208 LinDist3FlowApplicability, LinDist3FlowApplicabilityStatus, LinDist3FlowBuildOptions,
209 LinDist3FlowLimitCheck, LinDist3FlowLimitKind, LinDist3FlowNode, LinDist3FlowOpfInstance,
210 LinDist3FlowOpfSolution, LinDist3FlowOpfValues, LinDist3FlowPfInstance, LinDist3FlowPfSolution,
211 LinDist3FlowPreparationAction, LinDist3FlowPreparationActionKind,
212 LinDist3FlowPreparationReport, LinDist3FlowReferencePolicy, LinDist3FlowReferenceProvenance,
213 LinDist3FlowReferenceState, LinDist3FlowTopology, LinDist3FlowUnsupported, LoadAllocation,
214 McAcOpfInstance, McAcOpfSolution, McAcPfInstance, McAcPfSolution, NetworkUpdate,
215 OperatingPointUpdate, ReactivePower, ReactivePowerUnit, Termination,
216 ThreeWindingTransformerTerminalActivePower, ThreeWindingTransformerTerminalPower, UpdateChange,
217 UpdateReport, UpdatedField, apply_bus_load_active_power, apply_updates,
218 evaluate_lindist3flow_pf_limits,
219};
220
221#[cfg(feature = "matrix")]
226pub use powerio_matrix as matrix;
227
228#[cfg(feature = "gridfm")]
229#[doc(hidden)]
230#[path = "gridfm.rs"]
231pub mod __gridfm;
232pub mod codes;
233mod formats;
234pub use formats::{FormatInfo, GridFormatInfo, NetworkFamily, grid_formats, resolve_format};
235#[cfg(feature = "gridfm")]
236mod collect;
237pub mod dist_geo;
238#[cfg(feature = "gridfm")]
239pub use __gridfm::codes as gridfm_codes;
240mod stored;
241mod write;
242pub use write::{EmitOptions, emit, emit_with_options};
243#[cfg(feature = "sincal")]
244mod sincal_write;
245#[cfg(feature = "sincal")]
246pub use sincal_write::{ArchiveOptions, SincalContainer, SincalExperimentalOptions};
247mod ir;
248#[cfg(feature = "schema")]
249pub use ir::generate_ir_schema;
250pub use ir::{deserialize, serialize, serialize_diagnostics};
251pub mod transform;
252pub use transform::{
253 ac_ots_instance_from_base, ac_ots_solution, apply_geo_layer, fix_ac_ots_topology,
254 materialize_ac_ots_solution, network_with_operating_point, neutral_kron,
255 neutral_kron_with_options, to_ac_opf_instance, to_ac_ots_instance,
256 to_ac_ots_instance_with_options, to_ac_pf_instance, to_dc_opf_instance, to_dc_pf_instance,
257 to_lindist3flow_opf_instance, to_lindist3flow_opf_instance_with_options,
258 to_lindist3flow_pf_instance, to_lindist3flow_pf_instance_with_options, to_mc_ac_opf_instance,
259 to_mc_ac_pf_instance,
260};
261
262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
263enum Goc3DataFileKind {
264 Problem,
265 Solution,
266}
267
268#[derive(Default)]
269struct Goc3DataFiles {
270 problem: Option<SourceBuffer>,
271 solution: Option<SourceBuffer>,
272}
273
274impl Goc3DataFiles {
275 fn insert(&mut self, kind: Goc3DataFileKind, buffer: SourceBuffer) -> Result<()> {
276 let slot = match kind {
277 Goc3DataFileKind::Problem => &mut self.problem,
278 Goc3DataFileKind::Solution => &mut self.solution,
279 };
280 if let Some(existing) = slot {
281 return Err(Error::new(
282 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
283 format!(
284 "GO Challenge 3 source contains both `{}` and `{}` as {} data files",
285 existing.name(),
286 buffer.name(),
287 match kind {
288 Goc3DataFileKind::Problem => "problem",
289 Goc3DataFileKind::Solution => "solution",
290 }
291 ),
292 ));
293 }
294 *slot = Some(buffer);
295 Ok(())
296 }
297}
298
299#[derive(Default, serde::Deserialize)]
301struct Goc3Roots {
302 #[serde(default)]
303 network: Option<serde::de::IgnoredAny>,
304 #[serde(default)]
305 time_series_input: Option<serde::de::IgnoredAny>,
306 #[serde(default)]
307 reliability: Option<serde::de::IgnoredAny>,
308 #[serde(default)]
309 time_series_output: Option<serde::de::IgnoredAny>,
310}
311
312impl Goc3Roots {
313 fn is_problem(&self) -> bool {
314 self.network.is_some() && self.time_series_input.is_some() && self.reliability.is_some()
315 }
316
317 fn is_solution(&self) -> bool {
318 self.time_series_output.is_some()
319 }
320}
321
322fn goc3_roots(buffer: &SourceBuffer) -> Result<Option<Goc3Roots>> {
325 match serde_json::from_slice::<Goc3Roots>(buffer.content_bytes()) {
326 Ok(roots) => Ok(Some(roots)),
327 Err(error) if error.classify() == serde_json::error::Category::Data => Ok(None),
328 Err(error) => Err(Error::new(
329 &powerio_tx::diagnostics::codes::PARSE_GOC3_MALFORMED,
330 format!("{}: {error}", buffer.name()),
331 )),
332 }
333}
334
335fn goc3_file_kind(buffer: &SourceBuffer) -> Result<Option<Goc3DataFileKind>> {
336 let Some(roots) = goc3_roots(buffer)? else {
337 return Ok(None);
338 };
339 match (roots.is_problem(), roots.is_solution()) {
340 (true, false) => Ok(Some(Goc3DataFileKind::Problem)),
341 (false, true) => Ok(Some(Goc3DataFileKind::Solution)),
342 (false, false) => Ok(None),
343 (true, true) => Err(Error::new(
344 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
345 format!(
346 "{} contains both the GO Challenge 3 problem and solution roots",
347 buffer.name()
348 ),
349 )),
350 }
351}
352
353fn goc3_data_files(source: &Source) -> Result<Goc3DataFiles> {
354 let mut buffers = if source.is_directory() {
355 let mut buffers = Vec::new();
356 for name in source.entry_names()? {
357 if std::path::Path::new(name.as_str())
358 .extension()
359 .and_then(|extension| extension.to_str())
360 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
361 {
362 buffers.push(source.buffer(&name)?);
363 }
364 }
365 buffers
366 } else {
367 let mut buffers = vec![source.primary_buffer()?];
368 if let Ok(names) = source.entry_names() {
371 for name in names {
372 buffers.push(source.root_buffer(name.as_str())?);
373 }
374 }
375 buffers
376 };
377 buffers.sort_by(|left, right| left.name().cmp(right.name()));
378
379 let mut files = Goc3DataFiles::default();
380 for buffer in buffers {
381 if let Some(kind) = goc3_file_kind(&buffer)? {
382 files.insert(kind, buffer)?;
383 }
384 }
385 Ok(files)
386}
387
388fn directory_has_goc3_data(source: &Source) -> bool {
389 source.entry_names().is_ok_and(|names| {
390 names.into_iter().any(|name| {
391 std::path::Path::new(name.as_str())
392 .extension()
393 .and_then(|extension| extension.to_str())
394 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
395 && source.buffer(&name).is_ok_and(|buffer| {
396 goc3_roots(&buffer).is_ok_and(|roots| {
397 roots.is_some_and(|roots| roots.is_solution() || roots.is_problem())
398 })
399 })
400 })
401 })
402}
403
404pub fn to_geo_layer_from_aux_text(text: &str) -> Result<GeoLayer> {
413 let aux = powerio_tx::format::powerworld::aux_sections(text)
414 .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
415 Ok(powerio_tx::to_geo_layer_from_aux_substations(&aux))
416}
417
418pub use powerio_prob::OperatingPoint;
421mod value;
422pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
423
424#[derive(Clone, Debug, Default)]
427#[non_exhaustive]
428pub struct ParseOptions {
429 #[cfg(feature = "sincal")]
431 pub sincal_balanced: Option<powerio_tnd::formats::sincal::SincalBalancedReadOptions>,
432 #[cfg(feature = "sincal")]
435 pub sincal_multiconductor: Option<powerio_tnd::formats::sincal::SincalReadOptions>,
436 pub format: Option<powerio_core::FormatId>,
439 pub acquisition_root: Option<std::path::PathBuf>,
442}
443
444impl ParseOptions {
445 pub fn format(mut self, format: &str) -> std::result::Result<Self, powerio_core::Error> {
450 self.format = Some(powerio_core::FormatId::new(format)?);
451 Ok(self)
452 }
453
454 #[must_use]
456 pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
457 self.format = Some(format);
458 self
459 }
460
461 #[must_use]
463 pub fn acquisition_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
464 self.acquisition_root = Some(root.into());
465 self
466 }
467}
468
469pub fn parse(
511 input: impl powerio_core::IntoSource,
512) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
513 parse_with_options(input, &ParseOptions::default())
514}
515
516pub fn parse_with_options(
524 input: impl powerio_core::IntoSource,
525 options: &ParseOptions,
526) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
527 let mut source = input.into_source()?;
528 if let Some(root) = &options.acquisition_root {
529 source = source.with_acquisition_root(root.clone())?;
530 }
531 if let Some(format) = &options.format {
532 source = source.with_format(format.clone());
533 }
534 #[cfg(not(feature = "sincal"))]
535 if names_sincal_source(&source) {
536 return Err(sincal_feature_disabled().with_source(source));
537 }
538 #[cfg(feature = "sincal")]
539 if options.sincal_balanced.is_some() && options.sincal_multiconductor.is_some() {
540 return Err(powerio_core::Error::new(
541 &codes::REQUEST_SINCAL_OPTIONS_PROFILE,
542 "SINCAL selections must name exactly one electrical family",
543 )
544 .with_source(source));
545 }
546 #[cfg(feature = "sincal")]
547 if let Some(sincal) = &options.sincal_balanced {
548 return powerio_tnd::formats::sincal::parse_sincal_balanced_with_options(source, sincal)
549 .map(|m| m.map_value(PioValue::from));
550 }
551 #[cfg(feature = "sincal")]
552 if let Some(sincal) = &options.sincal_multiconductor {
553 if source
554 .format()
555 .and_then(|f| format::routing::parse_distribution_format(f.as_str()))
556 != Some(format::routing::DistributionFormat::SincalMulticonductor)
557 {
558 return Err(powerio_core::Error::new(
559 &codes::REQUEST_SINCAL_OPTIONS_PROFILE,
560 "sincal_multiconductor options require explicit format sincal-multiconductor",
561 )
562 .with_source(source));
563 }
564 return powerio_tnd::formats::sincal::parse_sincal(source, sincal)
565 .map(|m| m.map_value(PioValue::from));
566 }
567 #[cfg(feature = "sincal")]
568 if names_sincal_source(&source) {
569 if source.format().is_some_and(|f| {
570 format::routing::parse_distribution_format(f.as_str())
571 == Some(format::routing::DistributionFormat::SincalMulticonductor)
572 }) {
573 return dist::parse(source).map(|m| m.map_value(PioValue::from));
574 }
575 return powerio_tnd::formats::sincal::parse_balanced(source)
576 .map(|m| m.map_value(PioValue::from));
577 }
578 match routed_family(&source)? {
579 RoutedFamily::Goc3 => parse_goc3(source),
580 RoutedFamily::OpfData => powerio_prob::__internal::__decode_opfdata_solution(source)
581 .map(|module| module.map_value(PioValue::from)),
582 RoutedFamily::Distribution(detected) => {
583 let source = match (source.format(), detected) {
584 (None, Some(format)) => {
585 source.with_format(powerio_core::FormatId::new(format.name())?)
586 }
587 _ => source,
588 };
589 let pmd = source.format().is_some_and(|f| {
591 format::routing::parse_distribution_format(f.as_str())
592 == Some(format::routing::DistributionFormat::PmdJson)
593 });
594 if pmd && powerio_dist::pmd::is_multinetwork(&source) {
595 return powerio_dist::pmd::parse_multinetwork(source)
596 .map(|module| module.map_value(PioValue::from));
597 }
598 dist::parse(source).map(|module| module.map_value(PioValue::from))
599 }
600 RoutedFamily::PypsaDirectory => parse_pypsa(source),
601 #[cfg(feature = "gridfm")]
602 RoutedFamily::Gridfm => parse_gridfm(source),
603 RoutedFamily::Egret => parse_egret(source),
604 RoutedFamily::Geo => parse_geo_layer(source),
605 RoutedFamily::Contingency(kind) => parse_contingency_file(source, kind),
606 RoutedFamily::Balanced(json_class) => format::parse_with_json_class(source, json_class)
607 .map(|module| module.map_value(PioValue::from)),
608 }
609}
610
611fn parse_goc3(
617 source: powerio_core::Source,
618) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
619 let source = source.with_format(powerio_core::FormatId::new("goc3-json")?);
620 let files = match goc3_data_files(&source) {
621 Ok(files) => files,
622 Err(error) => return Err(error.with_source(source)),
623 };
624 let Some(problem) = files.problem else {
625 let message = if files.solution.is_some() {
626 "a GO Challenge 3 solution file requires the matching problem file in the same source"
627 } else {
628 "the source contains neither a GO Challenge 3 problem file nor a solution file"
629 };
630 return Err(Error::new(
631 &powerio_tx::diagnostics::codes::READ_GOC3_PROBLEM_REQUIRED,
632 message,
633 )
634 .with_source(source));
635 };
636
637 let (instance, diagnostics) =
638 match powerio_prob::__internal::__parse_goc3_problem_buffer(&problem) {
639 Ok(parsed) => parsed,
640 Err(error) => return Err(error.with_source(source)),
641 };
642 let value = match files.solution {
643 Some(solution) => {
644 let solution = match powerio_prob::__internal::__parse_goc3_output_buffer(
645 std::sync::Arc::new(instance),
646 &solution,
647 ) {
648 Ok(solution) => solution,
649 Err(error) => return Err(error.with_source(source)),
650 };
651 PioValue::from(solution)
652 }
653 None => PioValue::from(instance),
654 };
655 powerio_core::PioModule::parsed(value, source, diagnostics)
656}
657
658fn parse_geo_layer(
663 source: powerio_core::Source,
664) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
665 let name = source.name().to_owned();
666 let declared = source.format().map(|format| format.as_str().to_owned());
667 let is_display = declared.as_deref().is_some_and(is_pwd_display_token)
668 || std::path::Path::new(&name)
669 .extension()
670 .and_then(|extension| extension.to_str())
671 .is_some_and(|extension| extension.eq_ignore_ascii_case("pwd"));
672
673 let buffer = match source.primary_buffer() {
674 Ok(buffer) => buffer,
675 Err(error) => return Err(error.with_source(source)),
676 };
677 let (layer, diagnostics) = if is_display {
678 match powerio_tx::format::powerworld::__parse_pwd_layer(buffer.content_bytes()) {
679 Ok(parsed) => (parsed.layer, parsed.diagnostics),
680 Err(error) => {
681 return Err(Error::new(error.code(), error.to_string())
682 .with_cause(error)
683 .with_source(source));
684 }
685 }
686 } else {
687 let text = match std::str::from_utf8(buffer.content_bytes()) {
688 Ok(text) => text,
689 Err(cause) => {
690 return Err(Error::new(
691 &powerio_tx::diagnostics::codes::READ_GEO_NOT_TEXT,
692 format!("a geographic layer document is not valid UTF-8: {cause}"),
693 )
694 .with_source(source));
695 }
696 };
697 match powerio_tx::geo::GeoLayer::parse(
698 text,
699 std::path::Path::new(&name)
700 .file_name()
701 .and_then(|name| name.to_str()),
702 ) {
703 Ok(parsed) => (parsed.layer, parsed.diagnostics),
704 Err(error) => {
705 return Err(Error::new(error.code(), error.to_string())
706 .with_cause(error)
707 .with_source(source));
708 }
709 }
710 };
711 let source = match declared {
712 Some(_) => source,
713 None => source.with_format(powerio_core::FormatId::new(if is_display {
714 "powerworld-pwd"
715 } else {
716 "geo-json"
717 })?),
718 };
719 powerio_core::PioModule::parsed(PioValue::from(layer), source, diagnostics)
720}
721
722fn parse_contingency_file(
727 source: powerio_core::Source,
728 kind: ContingencyFile,
729) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
730 let buffer = match source.primary_buffer() {
731 Ok(buffer) => buffer,
732 Err(error) => return Err(error.with_source(source)),
733 };
734 let text = match std::str::from_utf8(buffer.content_bytes()) {
735 Ok(text) => text,
736 Err(cause) => {
737 return Err(Error::new(
738 kind.not_text_code(),
739 format!("{} is not valid UTF-8: {cause}", kind.description()),
740 )
741 .with_source(source));
742 }
743 };
744 let parsed = match kind {
745 ContingencyFile::Con => powerio_tx::ContingencySet::parse(text)
746 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
747 ContingencyFile::Sub => powerio_tx::SubsystemSet::parse(text)
748 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
749 ContingencyFile::Mon => powerio_tx::MonitoredSet::parse(text)
750 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
751 };
752 let (value, diagnostics) = match parsed {
753 Ok(parsed) => parsed,
754 Err(error) => {
755 return Err(Error::new(error.code(), error.to_string())
756 .with_cause(error)
757 .with_source(source));
758 }
759 };
760 let source = match source.format() {
761 Some(_) => source,
762 None => source.with_format(powerio_core::FormatId::new(kind.token())?),
763 };
764 powerio_core::PioModule::parsed(value, source, diagnostics)
765}
766
767fn parse_pypsa(
772 source: powerio_core::Source,
773) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
774 if !source.is_directory() {
775 return format::parse(source).map(|module| module.map_value(PioValue::from));
777 }
778 let source = match source.format() {
782 Some(_) => source,
783 None => source.with_format(powerio_core::FormatId::new("pypsa-csv")?),
784 };
785 let axis = match format::__pypsa_axis(&source) {
786 Ok(axis) => axis,
787 Err(error) => {
788 let core = powerio_core::Error::new(error.code(), error.to_string());
789 return Err(core.with_source(source));
790 }
791 };
792 match axis {
793 format::PypsaAxis::SingleSnapshot => {
794 format::parse(source).map(|module| module.map_value(PioValue::from))
795 }
796 format::PypsaAxis::Series => {
797 match powerio_prob::__internal::__decode_pypsa_sequence(&source) {
798 Ok((sequence, diagnostics)) => {
799 let value = match sequence {
800 powerio_prob::__internal::PypsaSequence::Networks(series) => {
801 PioValue::from(series)
802 }
803 powerio_prob::__internal::PypsaSequence::OperatingPoints(points) => {
804 PioValue::from(points)
805 }
806 };
807 powerio_core::PioModule::parsed(value, source, diagnostics)
808 }
809 Err(error) => Err(error.with_source(source)),
810 }
811 }
812 }
813}
814
815#[cfg(feature = "gridfm")]
818fn parse_gridfm(
819 source: powerio_core::Source,
820) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
821 if !source.is_directory() {
822 return format::parse(source).map(|module| module.map_value(PioValue::from));
824 }
825 let source = match source.format() {
826 Some(_) => source,
827 None => source.with_format(powerio_core::FormatId::new("gridfm")?),
828 };
829 match __gridfm::parse_gridfm_source(&source) {
830 Ok((set, diagnostics)) => {
831 powerio_core::PioModule::parsed(PioValue::from(set), source, diagnostics)
832 }
833 Err(error) => Err(error.with_source(source)),
834 }
835}
836
837fn parse_egret(
841 source: powerio_core::Source,
842) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
843 let declares_series = {
844 let buffer = source.primary_buffer()?;
845 std::str::from_utf8(buffer.content_bytes()).is_ok_and(format::__egret_declares_time_series)
846 };
847 if !declares_series {
848 return format::parse(source).map(|module| module.map_value(PioValue::from));
849 }
850 let parsed = {
851 let buffer = source.primary_buffer()?;
852 let stem = std::path::Path::new(source.name())
853 .file_stem()
854 .and_then(|stem| stem.to_str())
855 .map(str::to_owned);
856 match std::str::from_utf8(buffer.content_bytes()) {
857 Ok(text) => format::__parse_egret_time_series(text, stem.as_deref())
858 .map_err(|error| powerio_core::Error::new(error.code(), error.to_string())),
859 Err(error) => {
860 let cause = powerio_tx::Error::FormatRead {
861 format: "case text",
862 message: format!("not valid UTF-8: {error}"),
863 };
864 Err(powerio_core::Error::new(cause.code(), cause.to_string()))
865 }
866 }
867 };
868 match parsed {
869 Ok(series) => powerio_core::PioModule::parsed(PioValue::from(series), source, Vec::new()),
870 Err(error) => Err(error.with_source(source)),
871 }
872}
873
874enum RoutedFamily {
881 Balanced(Option<format::routing::JsonClass>),
882 Distribution(Option<format::routing::DistributionFormat>),
883 Goc3,
884 OpfData,
885 PypsaDirectory,
886 Egret,
887 Geo,
891 Contingency(ContingencyFile),
893 #[cfg(feature = "gridfm")]
894 Gridfm,
895}
896
897fn names_sincal_source(source: &powerio_core::Source) -> bool {
902 match source.format() {
903 Some(format) => write::is_sincal_format(format.as_str()),
904 None => std::path::Path::new(source.name())
905 .extension()
906 .is_some_and(|extension| extension.eq_ignore_ascii_case("sinx")),
907 }
908}
909
910#[cfg(not(feature = "sincal"))]
913pub(crate) fn sincal_feature_disabled() -> powerio_core::Error {
914 powerio_core::Error::new(
915 &powerio_tx::diagnostics::codes::REQUEST_SINCAL_FEATURE_DISABLED,
916 "this build of powerio compiled without the `sincal` feature, so it cannot read or write SINCAL projects; enable the `sincal` feature (on by default) to include the SQLite backed readers",
917 )
918}
919
920fn routed_family(
921 source: &powerio_core::Source,
922) -> std::result::Result<RoutedFamily, powerio_core::Error> {
923 if let Some(declared) = source.format() {
924 return Ok(family_of_token(declared.as_str()));
925 }
926 if source.is_directory() {
927 if directory_has_goc3_data(source) {
931 return Ok(RoutedFamily::Goc3);
932 }
933 let marker = powerio_core::ArtifactPath::new("network.csv")
937 .expect("static name is a valid artifact path");
938 if source.buffer(&marker).is_ok() {
939 return Ok(RoutedFamily::PypsaDirectory);
940 }
941 #[cfg(feature = "gridfm")]
942 if let Ok(entries) = source.entry_names()
943 && entries.iter().any(|entry| {
944 entry.as_str().ends_with("bus_data.parquet")
945 && matches!(entry.as_str().matches('/').count(), 0..=2)
946 })
947 {
948 return Ok(RoutedFamily::Gridfm);
949 }
950 if powerio_dist::source_looks_like_cim_dist(source) {
953 return Ok(RoutedFamily::Distribution(Some(
954 format::routing::DistributionFormat::CimDist,
955 )));
956 }
957 return Ok(RoutedFamily::Balanced(None));
958 }
959 let extension = std::path::Path::new(source.name())
960 .extension()
961 .and_then(|extension| extension.to_str())
962 .unwrap_or_default()
963 .to_ascii_lowercase();
964 if has_geo_layer_extension(source.name()) {
965 return Ok(RoutedFamily::Geo);
966 }
967 match extension.as_str() {
968 "glm" => Ok(RoutedFamily::Distribution(Some(
969 format::routing::DistributionFormat::Gridlabd,
970 ))),
971 "dss" => Ok(RoutedFamily::Distribution(Some(
972 format::routing::DistributionFormat::Dss,
973 ))),
974 "json" => json_family(source),
975 "xml" if powerio_dist::source_looks_like_cim_dist(source) => Ok(
978 RoutedFamily::Distribution(Some(format::routing::DistributionFormat::CimDist)),
979 ),
980 "pwd" | "geojson" => Ok(RoutedFamily::Geo),
985 "con" => Ok(RoutedFamily::Contingency(ContingencyFile::Con)),
986 "sub" => Ok(RoutedFamily::Contingency(ContingencyFile::Sub)),
987 "mon" => Ok(RoutedFamily::Contingency(ContingencyFile::Mon)),
988 "m" | "raw" | "aux" | "epc" | "pwb" | "uct" => Ok(RoutedFamily::Balanced(None)),
989 _ => {
990 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
991 std::str::from_utf8(buffer.content_bytes()).is_ok_and(|text| {
992 text.trim_start_matches('\u{feff}')
996 .trim_start()
997 .starts_with(['{', '['])
998 })
999 });
1000 if jsonish {
1001 json_family(source)
1002 } else if powerio_dist::source_looks_like_cim_dist(source) {
1003 Ok(RoutedFamily::Distribution(Some(
1004 format::routing::DistributionFormat::CimDist,
1005 )))
1006 } else {
1007 Ok(RoutedFamily::Balanced(None))
1008 }
1009 }
1010 }
1011}
1012
1013fn has_geo_layer_extension(name: &str) -> bool {
1020 let name = name.to_ascii_lowercase();
1021 let extension = powerio_tx::geo::GEO_LAYER_EXTENSION;
1022 name == extension
1023 || name
1024 .strip_suffix(extension)
1025 .is_some_and(|stem| stem.ends_with(['.', '_', '-', '/', '\\']))
1026}
1027
1028pub(crate) fn is_geo_layer_token(token: &str) -> bool {
1030 matches!(
1031 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
1032 "geojson" | "geo" | "geolayer"
1033 )
1034}
1035
1036pub(crate) fn is_pwd_display_token(token: &str) -> bool {
1039 matches!(
1040 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
1041 "pwd" | "powerworldpwd" | "powerworlddisplay"
1042 )
1043}
1044
1045fn is_geo_token(token: &str) -> bool {
1047 is_geo_layer_token(token) || is_pwd_display_token(token)
1048}
1049
1050#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1054pub(crate) enum ContingencyFile {
1055 Con,
1057 Sub,
1059 Mon,
1061}
1062
1063impl ContingencyFile {
1064 pub(crate) const fn token(self) -> &'static str {
1066 match self {
1067 Self::Con => "psse-con",
1068 Self::Sub => "psse-sub",
1069 Self::Mon => "psse-mon",
1070 }
1071 }
1072
1073 pub(crate) const fn extension(self) -> &'static str {
1075 match self {
1076 Self::Con => "con",
1077 Self::Sub => "sub",
1078 Self::Mon => "mon",
1079 }
1080 }
1081
1082 pub(crate) const fn artifact_name(self) -> &'static str {
1087 match self {
1088 Self::Con => "contingency.con",
1089 Self::Sub => "subsystem.sub",
1090 Self::Mon => "monitored.mon",
1091 }
1092 }
1093
1094 pub(crate) const fn type_name(self) -> &'static str {
1096 match self {
1097 Self::Con => "powerio.ContingencySet",
1098 Self::Sub => "powerio.SubsystemSet",
1099 Self::Mon => "powerio.MonitoredSet",
1100 }
1101 }
1102
1103 const fn not_text_code(self) -> &'static powerio_core::DiagnosticInfo {
1105 use powerio_tx::diagnostics::codes;
1106 match self {
1107 Self::Con => &codes::READ_CON_NOT_TEXT,
1108 Self::Sub => &codes::READ_SUB_NOT_TEXT,
1109 Self::Mon => &codes::READ_MON_NOT_TEXT,
1110 }
1111 }
1112
1113 const fn description(self) -> &'static str {
1115 match self {
1116 Self::Con => "a PSS/E contingency description file",
1117 Self::Sub => "a PSS/E subsystem description file",
1118 Self::Mon => "a PSS/E monitored element file",
1119 }
1120 }
1121}
1122
1123pub(crate) fn contingency_file_of_token(token: &str) -> Option<ContingencyFile> {
1127 match token.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
1128 "con" | "pssecon" | "contingency" => Some(ContingencyFile::Con),
1129 "sub" | "pssesub" | "subsystem" => Some(ContingencyFile::Sub),
1130 "mon" | "pssemon" | "monitored" => Some(ContingencyFile::Mon),
1131 _ => None,
1132 }
1133}
1134
1135fn json_family(
1137 source: &powerio_core::Source,
1138) -> std::result::Result<RoutedFamily, powerio_core::Error> {
1139 use format::routing::{Detection, JsonClass, SourceFormat, TransmissionFormat};
1140
1141 let buffer = source.primary_buffer()?;
1142 let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
1146 return Ok(RoutedFamily::Balanced(None));
1147 };
1148 let class = format::routing::classify_json_text(text);
1149 match class {
1150 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1151 TransmissionFormat::Goc3Json,
1152 ))) => Ok(RoutedFamily::Goc3),
1153 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1154 TransmissionFormat::DeepMindOpfDataJson,
1155 ))) => Ok(RoutedFamily::OpfData),
1156 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1157 TransmissionFormat::EgretJson,
1158 ))) => Ok(RoutedFamily::Egret),
1159 JsonClass::Case(Detection::Known(SourceFormat::Distribution(format))) => {
1160 Ok(RoutedFamily::Distribution(Some(format)))
1161 }
1162 JsonClass::Module => Err(powerio_core::Error::new(
1163 &codes::REQUEST_PARSE_POWERIO_IR,
1164 "PowerIO IR is not a grid exchange format; call deserialize(source)",
1165 )),
1166 JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
1170 Ok(RoutedFamily::Balanced(Some(class)))
1171 }
1172 }
1173}
1174
1175fn family_of_token(token: &str) -> RoutedFamily {
1178 use format::TargetFormat;
1179
1180 if is_geo_token(token) {
1181 return RoutedFamily::Geo;
1182 }
1183 if let Some(kind) = contingency_file_of_token(token) {
1184 return RoutedFamily::Contingency(kind);
1185 }
1186
1187 if let Some(format) = format::routing::parse_distribution_format(token) {
1188 return RoutedFamily::Distribution(Some(format));
1189 }
1190 if format::is_pypsa_csv_name(token) {
1191 return RoutedFamily::PypsaDirectory;
1192 }
1193 #[cfg(feature = "gridfm")]
1194 if token.eq_ignore_ascii_case("gridfm") {
1195 return RoutedFamily::Gridfm;
1196 }
1197 match format::parse_target_format(token) {
1198 Some(TargetFormat::Goc3Json) => RoutedFamily::Goc3,
1199 Some(TargetFormat::DeepMindOpfDataJson) => RoutedFamily::OpfData,
1200 Some(TargetFormat::EgretJson) => RoutedFamily::Egret,
1201 _ => RoutedFamily::Balanced(None),
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 fn memory(name: &str, text: &str) -> powerio_core::Source {
1210 powerio_core::Source::from_memory(name, text.as_bytes().to_vec()).expect("memory source")
1211 }
1212
1213 fn parse(
1214 source: powerio_core::Source,
1215 ) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
1216 super::parse(source)
1217 }
1218
1219 fn options(format: Option<&str>) -> ParseOptions {
1220 match format {
1221 Some(format) => ParseOptions::default().format(format).unwrap(),
1222 None => ParseOptions::default(),
1223 }
1224 }
1225
1226 fn assert_value_type(module: &powerio_core::PioModule<PioValue>, expected: &str) {
1227 assert_eq!(module.value().type_name(), expected);
1228 }
1229
1230 #[test]
1231 fn a_matpower_source_parses_to_a_balanced_network() {
1232 let case = "function mpc = case\n\
1233 mpc.version = '2';\n\
1234 mpc.baseMVA = 100;\n\
1235 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1236 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1237 mpc.branch = [];\n";
1238 let module = parse(
1239 memory("case.m", case).with_format(powerio_core::FormatId::new("matpower").unwrap()),
1240 )
1241 .expect("matpower parses");
1242 assert_value_type(&module, "powerio.BalancedNetwork");
1243 }
1244
1245 #[test]
1246 fn memory_parse_retains_its_name_and_optional_format() {
1247 let case = "function mpc = inline\n\
1248 mpc.version = '2';\n\
1249 mpc.baseMVA = 100;\n\
1250 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1251 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1252 mpc.branch = [];\n";
1253
1254 let detected = super::parse(memory("inline-case.m", case)).expect("name detects MATPOWER");
1255 assert_eq!(detected.source().unwrap().name(), "inline-case.m");
1256 assert_eq!(
1257 detected.source().unwrap().format().map(FormatId::as_str),
1258 Some("matpower")
1259 );
1260
1261 let declared = super::parse_with_options(
1262 memory("consumer-input", case),
1263 &ParseOptions::default().format("matpower").unwrap(),
1264 )
1265 .expect("declared MATPOWER");
1266 let source = declared.source().expect("source retained");
1267 assert_eq!(source.name(), "consumer-input");
1268 assert_eq!(source.format().map(FormatId::as_str), Some("matpower"));
1269 }
1270
1271 #[test]
1272 fn universal_parse_reads_declared_iso_8859_1_xiidm_and_retains_exact_bytes() {
1273 let text = r#"<?xml version="1.0" encoding="ISO-8859-1"?>
1274<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">
1275 <iidm:voltageLevel id="VL" nominalV="225" topologyKind="BUS_BREAKER">
1276 <iidm:busBreakerTopology><iidm:bus id="B" v="225" angle="0"/></iidm:busBreakerTopology>
1277 <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>
1278 </iidm:voltageLevel>
1279</iidm:network>"#;
1280 let bytes: Vec<u8> = text
1281 .chars()
1282 .map(|value| u8::try_from(u32::from(value)).expect("fixture is ISO-8859-1"))
1283 .collect();
1284 assert!(std::str::from_utf8(&bytes).is_err());
1285
1286 for (name, format) in [
1287 ("case.xiidm", None),
1288 ("case.xml", None),
1289 ("memory", Some("xiidm")),
1290 ] {
1291 let source = Source::from_memory(name, bytes.clone()).unwrap();
1292 let module = super::parse_with_options(source, &options(format)).unwrap();
1293 let PioValue::BalancedNetwork(network) = &module.value() else {
1294 panic!(
1295 "expected BalancedNetwork, got {}",
1296 module.value().type_name()
1297 );
1298 };
1299 assert_eq!(
1300 network.case_metadata().source_model_format.as_deref(),
1301 Some("Réseau PowSybl")
1302 );
1303 let retained = module.source().unwrap();
1304 assert_eq!(retained.format().map(FormatId::as_str), Some("xiidm"));
1305 assert_eq!(retained.primary_buffer().unwrap().bytes(), bytes);
1306
1307 let emitted =
1308 emit(&module, "xiidm", Destination::memory("copy.xiidm").unwrap()).unwrap();
1309 assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1310 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1311 panic!("memory destination returned a path output");
1312 };
1313 assert_eq!(artifacts.len(), 1);
1314 assert_eq!(artifacts[0].bytes(), bytes);
1315 }
1316 }
1317
1318 #[test]
1319 fn a_dss_source_parses_to_a_multiconductor_network() {
1320 let module = parse(memory(
1321 "feeder.dss",
1322 "New Circuit.c basekv=12.47 bus1=src\n",
1323 ))
1324 .expect("dss parses");
1325 let PioValue::MulticonductorNetwork(network) = &module.value() else {
1326 panic!(
1327 "expected multiconductor network, got {}",
1328 module.value().type_name()
1329 );
1330 };
1331 assert_eq!(network.name().as_deref(), Some("c"));
1332 }
1333
1334 #[test]
1335 fn distribution_cim_routes_by_content_and_echoes_its_bytes() {
1336 let fixture = concat!(
1337 env!("CARGO_MANIFEST_DIR"),
1338 "/../tests/data/dist/cim/micro_feeder.xml"
1339 );
1340 let bytes = std::fs::read(fixture).unwrap();
1341 let dir = tempfile::tempdir().unwrap();
1344 std::fs::write(dir.path().join("feeder.xml"), &bytes).unwrap();
1345 for source in [
1346 Source::from_memory("feeder.xml", bytes.clone()).unwrap(),
1347 Source::from_memory("<memory>", bytes.clone()).unwrap(),
1348 Source::open(dir.path()).unwrap(),
1349 ] {
1350 let module = parse(source).expect("distribution CIM parses");
1351 assert_value_type(&module, "powerio.MulticonductorNetwork");
1352 assert_eq!(
1353 module
1354 .source()
1355 .and_then(Source::format)
1356 .map(FormatId::as_str),
1357 Some("cim-dist")
1358 );
1359 }
1360 let module = parse(Source::from_memory("feeder.xml", bytes.clone()).unwrap()).unwrap();
1361 let emitted = emit(
1362 &module,
1363 "cim-dist",
1364 Destination::memory("copy.xml").unwrap(),
1365 )
1366 .unwrap();
1367 assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1368 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1369 panic!("memory destination returned a path output");
1370 };
1371 assert_eq!(artifacts[0].bytes(), bytes);
1372
1373 let PioValue::MulticonductorNetwork(network) = module.value() else {
1375 panic!("distribution CIM parses to a multiconductor network");
1376 };
1377 assert_eq!(
1378 *network.source_format(),
1379 Some(powerio_dist::DistSourceFormat::CimDist)
1380 );
1381 let stored = serialize(&module, Destination::memory("feeder.pio.json").unwrap()).unwrap();
1382 let EmittedOutput::Memory { artifacts } = stored.into_output() else {
1383 panic!("memory destination returned a path output");
1384 };
1385 let back = deserialize(
1386 Source::from_memory("feeder.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1387 )
1388 .unwrap();
1389 let PioValue::MulticonductorNetwork(network) = back.value() else {
1390 panic!("the IR keeps the multiconductor network");
1391 };
1392 assert_eq!(
1393 *network.source_format(),
1394 Some(powerio_dist::DistSourceFormat::CimDist)
1395 );
1396 }
1397
1398 #[test]
1399 fn a_declared_distribution_format_routes_without_an_extension() {
1400 let module = parse(
1401 memory("<memory>", "New Circuit.c basekv=12.47 bus1=src\n")
1402 .with_format(powerio_core::FormatId::new("dss").unwrap()),
1403 )
1404 .expect("declared dss parses");
1405 assert_value_type(&module, "powerio.MulticonductorNetwork");
1406 }
1407
1408 #[test]
1409 fn json_routes_by_top_level_markers() {
1410 let module = parse(memory(
1412 "feeder.json",
1413 r#"{"data_model": "ENGINEERING", "bus": {}}"#,
1414 ))
1415 .expect("pmd parses");
1416 assert_value_type(&module, "powerio.MulticonductorNetwork");
1417 }
1418
1419 #[test]
1420 fn a_bare_network_object_is_not_powerio_ir_or_a_case_format() {
1421 let error = parse(memory(
1422 "net.json",
1423 r#"{"name":"network","base_mva":100.0,"buses":[],"branches":[]}"#,
1424 ))
1425 .expect_err("an unmarked network object must not parse");
1426 assert!(error.to_string().contains("cannot infer JSON format"));
1427 }
1428
1429 #[test]
1430 fn the_error_path_retains_the_source() {
1431 let error = parse(memory("case.m", "not matpower at all")).expect_err("malformed");
1432 assert!(error.retained_source().is_some());
1433 }
1434
1435 fn fixture(path: &str) -> String {
1436 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1437 std::fs::read_to_string(root.join(path)).unwrap()
1438 }
1439
1440 #[test]
1441 fn goc3_parses_to_an_scuc_instance() {
1442 let text = fixture("../powerio-prob/tests/data/goc3_small.json");
1445 let module = parse(memory("goc3_small.json", &text)).expect("goc3 parses");
1446 assert_value_type(&module, "powerio.AcScucInstance");
1447 assert!(module.source().is_some());
1448 let PioValue::AcScucInstance(instance) = &module.value() else {
1449 unreachable!();
1450 };
1451 assert_eq!(instance.network().buses().len(), 2);
1452 }
1453
1454 #[test]
1455 fn goc3_problem_and_solution_parse_with_the_one_public_operation() {
1456 let problem = fixture("../tests/data/goc3/goc3_small.json");
1457 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1458 let source = powerio_core::Source::from_memory("problem.json", problem.into_bytes())
1459 .unwrap()
1460 .with_named_buffer("solution.json", solution.into_bytes())
1461 .unwrap();
1462 let module = parse(source).expect("problem and solution parse together");
1463 let PioValue::AcScucSolution(solution) = &module.value() else {
1464 panic!(
1465 "expected AC SCUC solution, got {}",
1466 module.value().type_name()
1467 );
1468 };
1469 assert_eq!(solution.instance().network().buses().len(), 2);
1470 assert_eq!(
1471 solution.network_outputs().shunt_step,
1472 vec![vec![1], vec![2]]
1473 );
1474 assert_eq!(module.sources().len(), 2);
1475
1476 let emitted = emit(
1477 &module,
1478 "goc3-json",
1479 Destination::memory("solution.json").unwrap(),
1480 )
1481 .expect("solution emits as official GOC3 output");
1482 let EmittedOutput::Memory { artifacts } = emitted.output() else {
1483 unreachable!();
1484 };
1485 assert_eq!(artifacts.len(), 1);
1486 let document: serde_json::Value = serde_json::from_slice(artifacts[0].bytes()).unwrap();
1487 assert!(document.get("time_series_output").is_some());
1488 assert!(document.get("network").is_none());
1489 }
1490
1491 #[test]
1492 fn goc3_solution_alone_names_the_missing_problem() {
1493 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1494 let error = parse(memory("solution.json", &solution))
1495 .expect_err("a solution without its problem is incomplete");
1496 assert!(error.to_string().contains("matching problem file"));
1497 assert!(error.retained_source().is_some());
1498 }
1499
1500 #[test]
1501 fn opfdata_parses_to_an_ac_opf_solution() {
1502 let text = fixture("../tests/data/opfdataset/example_0.json");
1503 let module = parse(memory("example_0.json", &text)).expect("opfdata parses");
1504 let PioValue::AcOpfSolution(solution) = &module.value() else {
1505 panic!(
1506 "expected AC OPF solution, got {}",
1507 module.value().type_name()
1508 );
1509 };
1510 assert_eq!(
1511 module
1512 .sources()
1513 .first()
1514 .and_then(|source| source.format())
1515 .map(powerio_core::FormatId::as_str),
1516 Some("opfdata-json")
1517 );
1518 assert_eq!(
1519 *solution.termination(),
1520 powerio_prob::Termination::NotReported
1521 );
1522 assert!((solution.objective() - 2_265.953_939_003_096).abs() < 1e-9);
1523
1524 let instance = solution.instance();
1525 assert_eq!(instance.network().buses().len(), 14);
1526 assert_eq!(instance.network().generators().len(), 5);
1527 let initial = instance.initial_point().expect("OPFData includes initials");
1528 let generator_id = instance.network().generators()[0]
1529 .uid
1530 .as_deref()
1531 .expect("parsed generators have stable identities");
1532 assert!((initial.generator_active_power(generator_id).unwrap() - 170.0).abs() < 1e-9);
1533 assert!((initial.generator_voltage_setpoint(generator_id).unwrap() - 1.0).abs() < 1e-12);
1534 assert!(solution.residuals().max_active_power_mismatch.unwrap() < 1.0);
1535 assert!(solution.residuals().max_reactive_power_mismatch.unwrap() < 1.0);
1536 }
1537
1538 #[test]
1539 fn malformed_opfdata_uses_the_universal_parse_error_path() {
1540 let error = parse(
1541 memory("broken.json", "{\"grid\": {}}")
1542 .with_format(powerio_core::FormatId::new("opfdata-json").unwrap()),
1543 )
1544 .expect_err("malformed OPFData");
1545 assert!(error.retained_source().is_some());
1546 }
1547
1548 const BMOPF_TINY: &str = r#"{
1549 "bus": {"a": {"terminal_names": ["1", "2", "3", "n"],
1550 "perfectly_grounded_terminals": ["n"]}},
1551 "voltage_source": {"s": {"bus": "a", "terminal_map": ["1", "2", "3"],
1552 "v_magnitude": [240.0, 240.0, 240.0], "v_angle": [0.0, -2.0944, 2.0944]}}
1553 }"#;
1554
1555 #[test]
1556 fn bmopf_parses_to_a_multiconductor_network() {
1557 let module = parse(memory("feeder.json", BMOPF_TINY)).expect("sniffed bmopf parses");
1560 assert_value_type(&module, "powerio.MulticonductorNetwork");
1561
1562 let module = parse(
1563 memory("<memory>", BMOPF_TINY)
1564 .with_format(powerio_core::FormatId::new("bmopf-json").unwrap()),
1565 )
1566 .expect("declared bmopf parses");
1567 assert_value_type(&module, "powerio.MulticonductorNetwork");
1568 }
1569
1570 #[test]
1571 fn nameless_json_text_routes_by_content() {
1572 let goc3 = fixture("../powerio-prob/tests/data/goc3_small.json");
1576 let module = parse(memory("<memory>", &goc3)).expect("nameless goc3 parses");
1577 assert_value_type(&module, "powerio.AcScucInstance");
1578
1579 let module = parse(memory("<memory>", BMOPF_TINY)).expect("nameless bmopf parses");
1580 assert_value_type(&module, "powerio.MulticonductorNetwork");
1581 }
1582
1583 #[test]
1584 fn a_declared_problem_format_that_fails_retains_the_source() {
1585 let error = parse(
1586 memory("broken.json", "{\"network\": {}}")
1587 .with_format(powerio_core::FormatId::new("goc3-json").unwrap()),
1588 )
1589 .expect_err("malformed goc3");
1590 assert!(error.retained_source().is_some());
1591 }
1592
1593 const PYPSA_STATIC: [(&str, &str); 4] = [
1594 ("network.csv", "name\nseq\n"),
1595 ("buses.csv", "name,v_nom\nB1,138.0\nB2,138.0\n"),
1596 ("loads.csv", "name,bus,p_set,q_set\nL1,B2,5.0,1.0\n"),
1597 (
1598 "generators.csv",
1599 "name,bus,control,p_nom,p_set\nG1,B1,Slack,100.0,12.0\n",
1600 ),
1601 ];
1602
1603 fn pypsa_folder(extra: &[(&str, &str)]) -> tempfile::TempDir {
1604 let temp = tempfile::tempdir().unwrap();
1605 for (name, content) in PYPSA_STATIC.iter().chain(extra) {
1606 std::fs::write(temp.path().join(name), content).unwrap();
1607 }
1608 temp
1609 }
1610
1611 #[test]
1612 fn a_pypsa_snapshot_parses_to_a_balanced_network() {
1613 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n")]);
1614 let module =
1615 parse(powerio_core::Source::open(dir.path()).unwrap()).expect("snapshot parses");
1616 assert_value_type(&module, "powerio.BalancedNetwork");
1617 }
1618
1619 #[test]
1620 fn a_pypsa_input_series_parses_to_a_network_time_series() {
1621 let dir = pypsa_folder(&[
1622 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1623 ("loads-p_set.csv", "snapshot,L1\nnow,10.0\nlater,20.0\n"),
1624 ]);
1625 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1626 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1627 assert!(module.source().is_some());
1628 let PioValue::TimeSeries(series) = &module.value() else {
1629 unreachable!();
1630 };
1631 assert_eq!(series.len(), 2);
1632 let PioValue::BalancedNetwork(later) = series.get(1).unwrap() else {
1633 unreachable!();
1634 };
1635 assert!((later.loads()[0].p - 20.0).abs() < 1e-12);
1636 }
1637
1638 #[test]
1639 fn a_pypsa_voltage_series_parses_to_operating_points() {
1640 let dir = pypsa_folder(&[
1641 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1642 (
1643 "buses-v_mag_pu.csv",
1644 "snapshot,B1,B2\nnow,1.0,0.99\nlater,1.0,0.97\n",
1645 ),
1646 (
1647 "buses-v_ang.csv",
1648 "snapshot,B1,B2\nnow,0.0,-0.017453292519943295\nlater,0.0,-0.03490658503988659\n",
1649 ),
1650 ]);
1651 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1652 assert_value_type(
1653 &module,
1654 "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>",
1655 );
1656 let PioValue::TimeSeries(series) = &module.value() else {
1657 unreachable!();
1658 };
1659 let PioValue::BalancedOperatingPoint(later) = series.get(1).unwrap() else {
1660 unreachable!();
1661 };
1662 assert!((later.bus_voltage_magnitude(powerio_tx::BusId(2)).unwrap() - 0.97).abs() < 1e-12);
1663 }
1664
1665 #[test]
1666 fn a_pypsa_axis_with_no_series_stays_a_network_time_series() {
1667 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n1,later\n")]);
1671 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("axis parses");
1672 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1673 }
1674
1675 const EGRET_SERIES: &str = r#"{
1676 "model_name": "uc2",
1677 "elements": {
1678 "bus": {"1": {"matpower_bustype": "ref", "base_kv": 138.0},
1679 "2": {"matpower_bustype": "PQ", "base_kv": 138.0}},
1680 "load": {"load_1": {"bus": "2",
1681 "p_load": {"data_type": "time_series", "values": [10.0, 20.0]},
1682 "q_load": 3.0}},
1683 "generator": {"1": {"bus": "1", "pg": 12.0, "qg": 0.0,
1684 "p_min": 0.0, "p_max": 50.0, "q_min": -10.0, "q_max": 10.0}},
1685 "branch": {"1": {"from_bus": "1", "to_bus": "2",
1686 "resistance": 0.01, "reactance": 0.1, "charging_susceptance": 0.0,
1687 "rating_long_term": 100.0, "rating_short_term": 100.0,
1688 "rating_emergency": 100.0, "transformer_phase_shift": 0.0}}
1689 },
1690 "system": {"baseMVA": 100.0, "time_keys": ["t1", "t2"]}
1691 }"#;
1692
1693 #[test]
1694 fn egret_time_keys_parse_to_a_network_time_series() {
1695 let module = parse(
1696 memory("uc2.json", EGRET_SERIES)
1697 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1698 )
1699 .expect("egret series parses");
1700 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1701 assert!(module.source().is_some());
1702
1703 let module = parse(memory("uc2.json", EGRET_SERIES)).expect("sniffed egret parses");
1705 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1706 }
1707
1708 #[cfg(feature = "gridfm")]
1709 #[test]
1710 fn powerio_ir_uses_deserialize_not_parse() {
1711 use powerio_tx::{Bus, BusId, BusType};
1712 let network = powerio_tx::BalancedNetwork::in_memory(
1713 "stored",
1714 100.0,
1715 vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1716 vec![],
1717 );
1718 let original = powerio_core::PioModule::new(PioValue::BalancedNetwork(network));
1719 let emitted = serialize(&original, Destination::memory("case.pio.json").unwrap())
1720 .expect("module serializes");
1721 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1722 unreachable!();
1723 };
1724 let module = deserialize(
1725 Source::from_memory("case.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1726 )
1727 .expect("module deserializes");
1728 assert_value_type(&module, "powerio.BalancedNetwork");
1729 assert!(module.source().is_some());
1730 }
1731
1732 #[cfg(feature = "gridfm")]
1733 #[test]
1734 fn a_gridfm_dataset_parses_to_a_scenario_set() {
1735 let case = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
1738 let base = powerio_tx::parse(powerio_core::Source::open(case).unwrap())
1739 .expect("case9 parses")
1740 .into_value();
1741 let mut varied = base.clone();
1742 varied.loads_mut()[0].p += 5.0;
1743 let out = tempfile::tempdir().unwrap();
1744 let snapshots = [
1745 powerio_matrix::GridfmSnapshot::new(&base, 0),
1746 powerio_matrix::GridfmSnapshot::new(&varied, 1),
1747 ];
1748 powerio_matrix::emit_gridfm_batch(
1749 &snapshots,
1750 out.path(),
1751 &powerio_matrix::GridfmOptions::default(),
1752 )
1753 .expect("dataset writes");
1754
1755 let module =
1756 parse(powerio_core::Source::open(out.path()).unwrap()).expect("dataset parses");
1757 assert_value_type(&module, "powerio.ScenarioSet<powerio.BalancedNetwork>");
1758 assert!(module.source().is_some());
1759 let PioValue::ScenarioSet(set) = &module.value() else {
1760 unreachable!();
1761 };
1762 assert_eq!(set.len(), 2);
1763 assert!(set.get("0").is_some());
1764 assert!(set.get("1").is_some());
1765 }
1766
1767 #[test]
1768 fn an_unrecognized_directory_is_refused_with_the_hub_wording() {
1769 let dir = tempfile::tempdir().unwrap();
1770 std::fs::write(dir.path().join("notes.txt"), "not a case").unwrap();
1771 let error =
1772 parse(powerio_core::Source::open(dir.path()).unwrap()).expect_err("refused directory");
1773 assert!(error.to_string().contains("directory"), "{error}");
1774 }
1775
1776 #[test]
1777 fn a_scalar_egret_document_stays_a_balanced_network() {
1778 let scalar = EGRET_SERIES
1779 .replace(r#", "time_keys": ["t1", "t2"]"#, "")
1780 .replace(
1781 r#"{"data_type": "time_series", "values": [10.0, 20.0]}"#,
1782 "10.0",
1783 );
1784 let module = parse(
1785 memory("uc2.json", &scalar)
1786 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1787 )
1788 .expect("scalar egret parses");
1789 assert_value_type(&module, "powerio.BalancedNetwork");
1790 }
1791}