1pub const VERSION: &str = env!("CARGO_PKG_VERSION");
82
83pub const IR_SCHEMA_NAME: &str = "pio-ir";
85
86pub const IR_VERSION: u64 = 2;
102
103pub const IR_MIN_VERSION: u64 = 2;
108
109pub const IR_SCHEMA_ID: &str = "https://powerio.dev/schema/pio-ir/2/0.11.3/schema.json";
112
113use powerio_tx::format;
114pub use powerio_tx::{
115 Area, AutomaticOrder, AutomaticSpec, AutomaticTarget, BalancedNetwork, Branch, BranchCharging,
116 BranchCurrentRatings, BranchRatingSet, BranchRef, BranchSolution, BranchSusceptanceFormula,
117 Bus, BusId, BusType, Canvas, Change, ChangeOp, ChangeUnit, ContingencyAction, ContingencyCase,
118 ContingencyParsed, ContingencyResolution, ContingencySet, CoordinateSpace, CoordsKind,
119 DEFAULT_BASE_FREQUENCY, Detection, ElementKey, Expanded, Extras, GenCaps, GenCost, Generator,
120 GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoMeta, GeoParsed, GeoTarget, Hvdc,
121 Impedance, IndexCore, IndexedNetwork, InterfaceMember, JSON_CLASSES, JoinName, JsonClass, Load,
122 LoadVoltageModel, Location, MonitorScope, MonitorStatement, MonitoredParsed,
123 MonitoredResolution, MonitoredSet, PsseEquipmentIndex, PwdDisplay, PwdSubstation, ResolvedCase,
124 ResolvedComponent, ResolvedInterface, ResolvedVoltageScope, RetainedStatement, Selector,
125 SelectorGroup, Shunt, ShuntBlock, SkipRule, SolverParams, SourceFormat, Storage, Subsystem,
126 SubsystemParsed, SubsystemSelector, SubsystemSet, Switch, SwitchedShuntControl,
127 SwitchedShuntMode, Transformer3W, TransformerControl, TransformerControlMode, UnresolvedAction,
128 UnresolvedMonitor, UnresolvedMonitorReason, UnresolvedReason, Winding, apply_substation_points,
129 calc_series_admittance_of, classify_json_bytes, classify_json_text, repair_values,
130 to_geo_layer_from_pwd, to_lonlat_from_pwd_mercator,
131};
132pub use powerio_tx::{contingency, geo, network, version};
137
138pub use powerio_core::diagnostic_codes;
139pub use powerio_core::{
142 ArtifactPath, ComponentId, Destination, Diagnostic, DiagnosticCode, DiagnosticId,
143 DiagnosticInfo, DiagnosticSeverity, DiagnosticStage, Digest, DigestAlgorithm, EmitResult,
144 EmittedOutput, Fidelity, FormatId, HistoryEntry, HistoryId, HistoryKind, MemoryArtifact,
145 OutputLayout, PioModule, Producer, Scenario, ScenarioId, ScenarioSet, Source, SourceBuffer,
146 SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan, StagedEdit, TimePoint,
147 TimeSeries,
148};
149
150pub use powerio_core::Error;
153pub type Result<T> = std::result::Result<T, powerio_core::Error>;
154
155pub use powerio_dist as dist;
159pub use powerio_dist::{
160 BmopfEmitOptions, BmopfSchemaVersion, ConductorMatrix, DistGeoMeta, DistGraphEdgeKind,
161 MulticonductorNetwork, NeutralKronOptions, NeutralKronReport,
162};
163
164pub use powerio_prob::solution::{SocwrOpfDuals, SocwrOpfSolution, SocwrOpfValues};
165pub use powerio_prob::{
169 AcBusSpecification, AcOpfInstance, AcOpfSolution, AcPfInstance, AcPfSolution, AcScucInstance,
170 AcScucSolution, ActivePower, ActivePowerUnit, ApparentPower, ApparentPowerUnit,
171 BalancedCalculationInstance, CalculationUpdate, DcBusSpecification, DcOpfInstance,
172 DcOpfSolution, DcPfInstance, DcPfSolution, LinDist3FlowApplicability,
173 LinDist3FlowApplicabilityStatus, LinDist3FlowBuildOptions, LinDist3FlowNode,
174 LinDist3FlowOpfInstance, LinDist3FlowOpfSolution, LinDist3FlowOpfValues,
175 LinDist3FlowReferencePolicy, LinDist3FlowReferenceProvenance, LinDist3FlowReferenceState,
176 LinDist3FlowTopology, LinDist3FlowUnsupported, LoadAllocation, McAcOpfInstance,
177 McAcOpfSolution, McAcPfInstance, McAcPfSolution, NetworkUpdate, OperatingPointUpdate,
178 ReactivePower, ReactivePowerUnit, Termination, ThreeWindingTransformerTerminalActivePower,
179 ThreeWindingTransformerTerminalPower, UpdateChange, UpdateReport, UpdatedField,
180 apply_bus_load_active_power, apply_updates,
181};
182
183#[cfg(feature = "matrix")]
188pub use powerio_matrix as matrix;
189
190#[cfg(feature = "gridfm")]
191#[doc(hidden)]
192#[path = "gridfm.rs"]
193pub mod __gridfm;
194pub mod codes;
195mod formats;
196pub use formats::{FormatInfo, resolve_format};
197#[cfg(feature = "gridfm")]
198mod collect;
199pub mod dist_geo;
200#[cfg(feature = "gridfm")]
201pub use __gridfm::codes as gridfm_codes;
202mod stored;
203mod write;
204pub use write::emit;
205mod ir;
206#[cfg(feature = "schema")]
207pub use ir::generate_ir_schema;
208pub use ir::{deserialize, serialize, serialize_diagnostics};
209pub mod transform;
210pub use transform::{
211 apply_geo_layer, network_with_operating_point, neutral_kron, neutral_kron_with_options,
212 to_ac_opf_instance, to_ac_pf_instance, to_dc_opf_instance, to_dc_pf_instance,
213 to_lindist3flow_opf_instance, to_lindist3flow_opf_instance_with_options, to_mc_ac_opf_instance,
214 to_mc_ac_pf_instance,
215};
216
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218enum Goc3DataFileKind {
219 Problem,
220 Solution,
221}
222
223#[derive(Default)]
224struct Goc3DataFiles {
225 problem: Option<SourceBuffer>,
226 solution: Option<SourceBuffer>,
227}
228
229impl Goc3DataFiles {
230 fn insert(&mut self, kind: Goc3DataFileKind, buffer: SourceBuffer) -> Result<()> {
231 let slot = match kind {
232 Goc3DataFileKind::Problem => &mut self.problem,
233 Goc3DataFileKind::Solution => &mut self.solution,
234 };
235 if let Some(existing) = slot {
236 return Err(Error::new(
237 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
238 format!(
239 "GO Challenge 3 source contains both `{}` and `{}` as {} data files",
240 existing.name(),
241 buffer.name(),
242 match kind {
243 Goc3DataFileKind::Problem => "problem",
244 Goc3DataFileKind::Solution => "solution",
245 }
246 ),
247 ));
248 }
249 *slot = Some(buffer);
250 Ok(())
251 }
252}
253
254#[derive(Default, serde::Deserialize)]
256struct Goc3Roots {
257 #[serde(default)]
258 network: Option<serde::de::IgnoredAny>,
259 #[serde(default)]
260 time_series_input: Option<serde::de::IgnoredAny>,
261 #[serde(default)]
262 reliability: Option<serde::de::IgnoredAny>,
263 #[serde(default)]
264 time_series_output: Option<serde::de::IgnoredAny>,
265}
266
267impl Goc3Roots {
268 fn is_problem(&self) -> bool {
269 self.network.is_some() && self.time_series_input.is_some() && self.reliability.is_some()
270 }
271
272 fn is_solution(&self) -> bool {
273 self.time_series_output.is_some()
274 }
275}
276
277fn goc3_roots(buffer: &SourceBuffer) -> Result<Option<Goc3Roots>> {
280 match serde_json::from_slice::<Goc3Roots>(buffer.content_bytes()) {
281 Ok(roots) => Ok(Some(roots)),
282 Err(error) if error.classify() == serde_json::error::Category::Data => Ok(None),
283 Err(error) => Err(Error::new(
284 &powerio_tx::diagnostics::codes::PARSE_GOC3_MALFORMED,
285 format!("{}: {error}", buffer.name()),
286 )),
287 }
288}
289
290fn goc3_file_kind(buffer: &SourceBuffer) -> Result<Option<Goc3DataFileKind>> {
291 let Some(roots) = goc3_roots(buffer)? else {
292 return Ok(None);
293 };
294 match (roots.is_problem(), roots.is_solution()) {
295 (true, false) => Ok(Some(Goc3DataFileKind::Problem)),
296 (false, true) => Ok(Some(Goc3DataFileKind::Solution)),
297 (false, false) => Ok(None),
298 (true, true) => Err(Error::new(
299 &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
300 format!(
301 "{} contains both the GO Challenge 3 problem and solution roots",
302 buffer.name()
303 ),
304 )),
305 }
306}
307
308fn goc3_data_files(source: &Source) -> Result<Goc3DataFiles> {
309 let mut buffers = if source.is_directory() {
310 let mut buffers = Vec::new();
311 for name in source.entry_names()? {
312 if std::path::Path::new(name.as_str())
313 .extension()
314 .and_then(|extension| extension.to_str())
315 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
316 {
317 buffers.push(source.buffer(&name)?);
318 }
319 }
320 buffers
321 } else {
322 let mut buffers = vec![source.primary_buffer()?];
323 if let Ok(names) = source.entry_names() {
326 for name in names {
327 buffers.push(source.root_buffer(name.as_str())?);
328 }
329 }
330 buffers
331 };
332 buffers.sort_by(|left, right| left.name().cmp(right.name()));
333
334 let mut files = Goc3DataFiles::default();
335 for buffer in buffers {
336 if let Some(kind) = goc3_file_kind(&buffer)? {
337 files.insert(kind, buffer)?;
338 }
339 }
340 Ok(files)
341}
342
343fn directory_has_goc3_data(source: &Source) -> bool {
344 source.entry_names().is_ok_and(|names| {
345 names.into_iter().any(|name| {
346 std::path::Path::new(name.as_str())
347 .extension()
348 .and_then(|extension| extension.to_str())
349 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
350 && source.buffer(&name).is_ok_and(|buffer| {
351 goc3_roots(&buffer).is_ok_and(|roots| {
352 roots.is_some_and(|roots| roots.is_solution() || roots.is_problem())
353 })
354 })
355 })
356 })
357}
358
359pub fn to_geo_layer_from_aux_text(text: &str) -> Result<GeoLayer> {
368 let aux = powerio_tx::format::powerworld::aux_sections(text)
369 .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
370 Ok(powerio_tx::to_geo_layer_from_aux_substations(&aux))
371}
372
373pub use powerio_prob::OperatingPoint;
376mod value;
377pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
378
379#[derive(Clone, Debug, Default)]
382#[non_exhaustive]
383pub struct ParseOptions {
384 pub format: Option<powerio_core::FormatId>,
387 pub acquisition_root: Option<std::path::PathBuf>,
390}
391
392impl ParseOptions {
393 pub fn format(mut self, format: &str) -> std::result::Result<Self, powerio_core::Error> {
398 self.format = Some(powerio_core::FormatId::new(format)?);
399 Ok(self)
400 }
401
402 #[must_use]
404 pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
405 self.format = Some(format);
406 self
407 }
408
409 #[must_use]
411 pub fn acquisition_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
412 self.acquisition_root = Some(root.into());
413 self
414 }
415}
416
417pub fn parse(
455 input: impl powerio_core::IntoSource,
456) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
457 parse_with_options(input, &ParseOptions::default())
458}
459
460pub fn parse_with_options(
468 input: impl powerio_core::IntoSource,
469 options: &ParseOptions,
470) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
471 let mut source = input.into_source()?;
472 if let Some(root) = &options.acquisition_root {
473 source = source.with_acquisition_root(root.clone())?;
474 }
475 if let Some(format) = &options.format {
476 source = source.with_format(format.clone());
477 }
478 match routed_family(&source)? {
479 RoutedFamily::Goc3 => parse_goc3(source),
480 RoutedFamily::OpfData => powerio_prob::__internal::__decode_opfdata_solution(source)
481 .map(|module| module.map_value(PioValue::from)),
482 RoutedFamily::Distribution(detected) => {
483 let source = match (source.format(), detected) {
484 (None, Some(format)) => {
485 source.with_format(powerio_core::FormatId::new(format.name())?)
486 }
487 _ => source,
488 };
489 powerio_dist::parse(source).map(|module| module.map_value(PioValue::from))
490 }
491 RoutedFamily::PypsaDirectory => parse_pypsa(source),
492 #[cfg(feature = "gridfm")]
493 RoutedFamily::Gridfm => parse_gridfm(source),
494 RoutedFamily::Egret => parse_egret(source),
495 RoutedFamily::Geo => parse_geo_layer(source),
496 RoutedFamily::Contingency(kind) => parse_contingency_file(source, kind),
497 RoutedFamily::Balanced(json_class) => format::parse_with_json_class(source, json_class)
498 .map(|module| module.map_value(PioValue::from)),
499 }
500}
501
502fn parse_goc3(
508 source: powerio_core::Source,
509) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
510 let source = source.with_format(powerio_core::FormatId::new("goc3-json")?);
511 let files = match goc3_data_files(&source) {
512 Ok(files) => files,
513 Err(error) => return Err(error.with_source(source)),
514 };
515 let Some(problem) = files.problem else {
516 let message = if files.solution.is_some() {
517 "a GO Challenge 3 solution file requires the matching problem file in the same source"
518 } else {
519 "the source contains neither a GO Challenge 3 problem file nor a solution file"
520 };
521 return Err(Error::new(
522 &powerio_tx::diagnostics::codes::READ_GOC3_PROBLEM_REQUIRED,
523 message,
524 )
525 .with_source(source));
526 };
527
528 let (instance, diagnostics) =
529 match powerio_prob::__internal::__parse_goc3_problem_buffer(&problem) {
530 Ok(parsed) => parsed,
531 Err(error) => return Err(error.with_source(source)),
532 };
533 let value = match files.solution {
534 Some(solution) => {
535 let solution = match powerio_prob::__internal::__parse_goc3_output_buffer(
536 std::sync::Arc::new(instance),
537 &solution,
538 ) {
539 Ok(solution) => solution,
540 Err(error) => return Err(error.with_source(source)),
541 };
542 PioValue::from(solution)
543 }
544 None => PioValue::from(instance),
545 };
546 powerio_core::PioModule::parsed(value, source, diagnostics)
547}
548
549fn parse_geo_layer(
554 source: powerio_core::Source,
555) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
556 let name = source.name().to_owned();
557 let declared = source.format().map(|format| format.as_str().to_owned());
558 let is_display = declared.as_deref().is_some_and(is_pwd_display_token)
559 || std::path::Path::new(&name)
560 .extension()
561 .and_then(|extension| extension.to_str())
562 .is_some_and(|extension| extension.eq_ignore_ascii_case("pwd"));
563
564 let buffer = match source.primary_buffer() {
565 Ok(buffer) => buffer,
566 Err(error) => return Err(error.with_source(source)),
567 };
568 let (layer, diagnostics) = if is_display {
569 match powerio_tx::format::powerworld::__parse_pwd_layer(buffer.content_bytes()) {
570 Ok(parsed) => (parsed.layer, parsed.diagnostics),
571 Err(error) => {
572 return Err(Error::new(error.code(), error.to_string())
573 .with_cause(error)
574 .with_source(source));
575 }
576 }
577 } else {
578 let text = match std::str::from_utf8(buffer.content_bytes()) {
579 Ok(text) => text,
580 Err(cause) => {
581 return Err(Error::new(
582 &powerio_tx::diagnostics::codes::READ_GEO_NOT_TEXT,
583 format!("a geographic layer document is not valid UTF-8: {cause}"),
584 )
585 .with_source(source));
586 }
587 };
588 match powerio_tx::geo::GeoLayer::parse(
589 text,
590 std::path::Path::new(&name)
591 .file_name()
592 .and_then(|name| name.to_str()),
593 ) {
594 Ok(parsed) => (parsed.layer, parsed.diagnostics),
595 Err(error) => {
596 return Err(Error::new(error.code(), error.to_string())
597 .with_cause(error)
598 .with_source(source));
599 }
600 }
601 };
602 let source = match declared {
603 Some(_) => source,
604 None => source.with_format(powerio_core::FormatId::new(if is_display {
605 "powerworld-pwd"
606 } else {
607 "geo-json"
608 })?),
609 };
610 powerio_core::PioModule::parsed(PioValue::from(layer), source, diagnostics)
611}
612
613fn parse_contingency_file(
618 source: powerio_core::Source,
619 kind: ContingencyFile,
620) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
621 let buffer = match source.primary_buffer() {
622 Ok(buffer) => buffer,
623 Err(error) => return Err(error.with_source(source)),
624 };
625 let text = match std::str::from_utf8(buffer.content_bytes()) {
626 Ok(text) => text,
627 Err(cause) => {
628 return Err(Error::new(
629 kind.not_text_code(),
630 format!("{} is not valid UTF-8: {cause}", kind.description()),
631 )
632 .with_source(source));
633 }
634 };
635 let parsed = match kind {
636 ContingencyFile::Con => powerio_tx::ContingencySet::parse(text)
637 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
638 ContingencyFile::Sub => powerio_tx::SubsystemSet::parse(text)
639 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
640 ContingencyFile::Mon => powerio_tx::MonitoredSet::parse(text)
641 .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
642 };
643 let (value, diagnostics) = match parsed {
644 Ok(parsed) => parsed,
645 Err(error) => {
646 return Err(Error::new(error.code(), error.to_string())
647 .with_cause(error)
648 .with_source(source));
649 }
650 };
651 let source = match source.format() {
652 Some(_) => source,
653 None => source.with_format(powerio_core::FormatId::new(kind.token())?),
654 };
655 powerio_core::PioModule::parsed(value, source, diagnostics)
656}
657
658fn parse_pypsa(
663 source: powerio_core::Source,
664) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
665 if !source.is_directory() {
666 return format::parse(source).map(|module| module.map_value(PioValue::from));
668 }
669 let source = match source.format() {
673 Some(_) => source,
674 None => source.with_format(powerio_core::FormatId::new("pypsa-csv")?),
675 };
676 let axis = match format::__pypsa_axis(&source) {
677 Ok(axis) => axis,
678 Err(error) => {
679 let core = powerio_core::Error::new(error.code(), error.to_string());
680 return Err(core.with_source(source));
681 }
682 };
683 match axis {
684 format::PypsaAxis::SingleSnapshot => {
685 format::parse(source).map(|module| module.map_value(PioValue::from))
686 }
687 format::PypsaAxis::Series => {
688 match powerio_prob::__internal::__decode_pypsa_sequence(&source) {
689 Ok((sequence, diagnostics)) => {
690 let value = match sequence {
691 powerio_prob::__internal::PypsaSequence::Networks(series) => {
692 PioValue::from(series)
693 }
694 powerio_prob::__internal::PypsaSequence::OperatingPoints(points) => {
695 PioValue::from(points)
696 }
697 };
698 powerio_core::PioModule::parsed(value, source, diagnostics)
699 }
700 Err(error) => Err(error.with_source(source)),
701 }
702 }
703 }
704}
705
706#[cfg(feature = "gridfm")]
709fn parse_gridfm(
710 source: powerio_core::Source,
711) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
712 if !source.is_directory() {
713 return format::parse(source).map(|module| module.map_value(PioValue::from));
715 }
716 let source = match source.format() {
717 Some(_) => source,
718 None => source.with_format(powerio_core::FormatId::new("gridfm")?),
719 };
720 match __gridfm::parse_gridfm_source(&source) {
721 Ok((set, diagnostics)) => {
722 powerio_core::PioModule::parsed(PioValue::from(set), source, diagnostics)
723 }
724 Err(error) => Err(error.with_source(source)),
725 }
726}
727
728fn parse_egret(
732 source: powerio_core::Source,
733) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
734 let declares_series = {
735 let buffer = source.primary_buffer()?;
736 std::str::from_utf8(buffer.content_bytes()).is_ok_and(format::__egret_declares_time_series)
737 };
738 if !declares_series {
739 return format::parse(source).map(|module| module.map_value(PioValue::from));
740 }
741 let parsed = {
742 let buffer = source.primary_buffer()?;
743 let stem = std::path::Path::new(source.name())
744 .file_stem()
745 .and_then(|stem| stem.to_str())
746 .map(str::to_owned);
747 match std::str::from_utf8(buffer.content_bytes()) {
748 Ok(text) => format::__parse_egret_time_series(text, stem.as_deref())
749 .map_err(|error| powerio_core::Error::new(error.code(), error.to_string())),
750 Err(error) => {
751 let cause = powerio_tx::Error::FormatRead {
752 format: "case text",
753 message: format!("not valid UTF-8: {error}"),
754 };
755 Err(powerio_core::Error::new(cause.code(), cause.to_string()))
756 }
757 }
758 };
759 match parsed {
760 Ok(series) => powerio_core::PioModule::parsed(PioValue::from(series), source, Vec::new()),
761 Err(error) => Err(error.with_source(source)),
762 }
763}
764
765enum RoutedFamily {
772 Balanced(Option<format::routing::JsonClass>),
773 Distribution(Option<format::routing::DistributionFormat>),
774 Goc3,
775 OpfData,
776 PypsaDirectory,
777 Egret,
778 Geo,
782 Contingency(ContingencyFile),
784 #[cfg(feature = "gridfm")]
785 Gridfm,
786}
787
788fn routed_family(
789 source: &powerio_core::Source,
790) -> std::result::Result<RoutedFamily, powerio_core::Error> {
791 if let Some(declared) = source.format() {
792 return Ok(family_of_token(declared.as_str()));
793 }
794 if source.is_directory() {
795 if directory_has_goc3_data(source) {
799 return Ok(RoutedFamily::Goc3);
800 }
801 let marker = powerio_core::ArtifactPath::new("network.csv")
805 .expect("static name is a valid artifact path");
806 if source.buffer(&marker).is_ok() {
807 return Ok(RoutedFamily::PypsaDirectory);
808 }
809 #[cfg(feature = "gridfm")]
810 if let Ok(entries) = source.entry_names()
811 && entries.iter().any(|entry| {
812 entry.as_str().ends_with("bus_data.parquet")
813 && matches!(entry.as_str().matches('/').count(), 0..=2)
814 })
815 {
816 return Ok(RoutedFamily::Gridfm);
817 }
818 return Ok(RoutedFamily::Balanced(None));
819 }
820 let extension = std::path::Path::new(source.name())
821 .extension()
822 .and_then(|extension| extension.to_str())
823 .unwrap_or_default()
824 .to_ascii_lowercase();
825 if has_geo_layer_extension(source.name()) {
826 return Ok(RoutedFamily::Geo);
827 }
828 match extension.as_str() {
829 "dss" => Ok(RoutedFamily::Distribution(Some(
830 format::routing::DistributionFormat::Dss,
831 ))),
832 "json" => json_family(source),
833 "pwd" | "geojson" => Ok(RoutedFamily::Geo),
838 "con" => Ok(RoutedFamily::Contingency(ContingencyFile::Con)),
839 "sub" => Ok(RoutedFamily::Contingency(ContingencyFile::Sub)),
840 "mon" => Ok(RoutedFamily::Contingency(ContingencyFile::Mon)),
841 "m" | "raw" | "aux" | "epc" | "pwb" | "uct" => Ok(RoutedFamily::Balanced(None)),
842 _ => {
843 let jsonish = source.primary_buffer().is_ok_and(|buffer| {
844 std::str::from_utf8(buffer.content_bytes()).is_ok_and(|text| {
845 text.trim_start_matches('\u{feff}')
849 .trim_start()
850 .starts_with(['{', '['])
851 })
852 });
853 if jsonish {
854 json_family(source)
855 } else {
856 Ok(RoutedFamily::Balanced(None))
857 }
858 }
859 }
860}
861
862fn has_geo_layer_extension(name: &str) -> bool {
869 let name = name.to_ascii_lowercase();
870 let extension = powerio_tx::geo::GEO_LAYER_EXTENSION;
871 name == extension
872 || name
873 .strip_suffix(extension)
874 .is_some_and(|stem| stem.ends_with(['.', '_', '-', '/', '\\']))
875}
876
877pub(crate) fn is_geo_layer_token(token: &str) -> bool {
879 matches!(
880 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
881 "geojson" | "geo" | "geolayer"
882 )
883}
884
885pub(crate) fn is_pwd_display_token(token: &str) -> bool {
888 matches!(
889 token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
890 "pwd" | "powerworldpwd" | "powerworlddisplay"
891 )
892}
893
894fn is_geo_token(token: &str) -> bool {
896 is_geo_layer_token(token) || is_pwd_display_token(token)
897}
898
899#[derive(Clone, Copy, Debug, Eq, PartialEq)]
903pub(crate) enum ContingencyFile {
904 Con,
906 Sub,
908 Mon,
910}
911
912impl ContingencyFile {
913 pub(crate) const fn token(self) -> &'static str {
915 match self {
916 Self::Con => "psse-con",
917 Self::Sub => "psse-sub",
918 Self::Mon => "psse-mon",
919 }
920 }
921
922 pub(crate) const fn extension(self) -> &'static str {
924 match self {
925 Self::Con => "con",
926 Self::Sub => "sub",
927 Self::Mon => "mon",
928 }
929 }
930
931 pub(crate) const fn artifact_name(self) -> &'static str {
936 match self {
937 Self::Con => "contingency.con",
938 Self::Sub => "subsystem.sub",
939 Self::Mon => "monitored.mon",
940 }
941 }
942
943 pub(crate) const fn type_name(self) -> &'static str {
945 match self {
946 Self::Con => "powerio.ContingencySet",
947 Self::Sub => "powerio.SubsystemSet",
948 Self::Mon => "powerio.MonitoredSet",
949 }
950 }
951
952 const fn not_text_code(self) -> &'static powerio_core::DiagnosticInfo {
954 use powerio_tx::diagnostics::codes;
955 match self {
956 Self::Con => &codes::READ_CON_NOT_TEXT,
957 Self::Sub => &codes::READ_SUB_NOT_TEXT,
958 Self::Mon => &codes::READ_MON_NOT_TEXT,
959 }
960 }
961
962 const fn description(self) -> &'static str {
964 match self {
965 Self::Con => "a PSS/E contingency description file",
966 Self::Sub => "a PSS/E subsystem description file",
967 Self::Mon => "a PSS/E monitored element file",
968 }
969 }
970}
971
972pub(crate) fn contingency_file_of_token(token: &str) -> Option<ContingencyFile> {
976 match token.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
977 "con" | "pssecon" | "contingency" => Some(ContingencyFile::Con),
978 "sub" | "pssesub" | "subsystem" => Some(ContingencyFile::Sub),
979 "mon" | "pssemon" | "monitored" => Some(ContingencyFile::Mon),
980 _ => None,
981 }
982}
983
984fn json_family(
986 source: &powerio_core::Source,
987) -> std::result::Result<RoutedFamily, powerio_core::Error> {
988 use format::routing::{Detection, JsonClass, SourceFormat, TransmissionFormat};
989
990 let buffer = source.primary_buffer()?;
991 let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
995 return Ok(RoutedFamily::Balanced(None));
996 };
997 let class = format::routing::classify_json_text(text);
998 match class {
999 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1000 TransmissionFormat::Goc3Json,
1001 ))) => Ok(RoutedFamily::Goc3),
1002 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1003 TransmissionFormat::DeepMindOpfDataJson,
1004 ))) => Ok(RoutedFamily::OpfData),
1005 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1006 TransmissionFormat::EgretJson,
1007 ))) => Ok(RoutedFamily::Egret),
1008 JsonClass::Case(Detection::Known(SourceFormat::Distribution(format))) => {
1009 Ok(RoutedFamily::Distribution(Some(format)))
1010 }
1011 JsonClass::Module => Err(powerio_core::Error::new(
1012 &codes::REQUEST_PARSE_POWERIO_IR,
1013 "PowerIO IR is not a grid exchange format; call deserialize(source)",
1014 )),
1015 JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
1019 Ok(RoutedFamily::Balanced(Some(class)))
1020 }
1021 }
1022}
1023
1024fn family_of_token(token: &str) -> RoutedFamily {
1027 use format::TargetFormat;
1028
1029 if is_geo_token(token) {
1030 return RoutedFamily::Geo;
1031 }
1032 if let Some(kind) = contingency_file_of_token(token) {
1033 return RoutedFamily::Contingency(kind);
1034 }
1035
1036 if powerio_dist::parse_dist_target_format(token).is_some() {
1037 return RoutedFamily::Distribution(None);
1038 }
1039 if format::is_pypsa_csv_name(token) {
1040 return RoutedFamily::PypsaDirectory;
1041 }
1042 #[cfg(feature = "gridfm")]
1043 if token.eq_ignore_ascii_case("gridfm") {
1044 return RoutedFamily::Gridfm;
1045 }
1046 match format::parse_target_format(token) {
1047 Some(TargetFormat::Goc3Json) => RoutedFamily::Goc3,
1048 Some(TargetFormat::DeepMindOpfDataJson) => RoutedFamily::OpfData,
1049 Some(TargetFormat::EgretJson) => RoutedFamily::Egret,
1050 _ => RoutedFamily::Balanced(None),
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 fn memory(name: &str, text: &str) -> powerio_core::Source {
1059 powerio_core::Source::from_memory(name, text.as_bytes().to_vec()).expect("memory source")
1060 }
1061
1062 fn parse(
1063 source: powerio_core::Source,
1064 ) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
1065 super::parse(source)
1066 }
1067
1068 fn options(format: Option<&str>) -> ParseOptions {
1069 match format {
1070 Some(format) => ParseOptions::default().format(format).unwrap(),
1071 None => ParseOptions::default(),
1072 }
1073 }
1074
1075 fn assert_value_type(module: &powerio_core::PioModule<PioValue>, expected: &str) {
1076 assert_eq!(module.value().type_name(), expected);
1077 }
1078
1079 #[test]
1080 fn a_matpower_source_parses_to_a_balanced_network() {
1081 let case = "function mpc = case\n\
1082 mpc.version = '2';\n\
1083 mpc.baseMVA = 100;\n\
1084 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1085 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1086 mpc.branch = [];\n";
1087 let module = parse(
1088 memory("case.m", case).with_format(powerio_core::FormatId::new("matpower").unwrap()),
1089 )
1090 .expect("matpower parses");
1091 assert_value_type(&module, "powerio.BalancedNetwork");
1092 }
1093
1094 #[test]
1095 fn memory_parse_retains_its_name_and_optional_format() {
1096 let case = "function mpc = inline\n\
1097 mpc.version = '2';\n\
1098 mpc.baseMVA = 100;\n\
1099 mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1100 mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1101 mpc.branch = [];\n";
1102
1103 let detected = super::parse(memory("inline-case.m", case)).expect("name detects MATPOWER");
1104 assert_eq!(detected.source().unwrap().name(), "inline-case.m");
1105 assert_eq!(
1106 detected.source().unwrap().format().map(FormatId::as_str),
1107 Some("matpower")
1108 );
1109
1110 let declared = super::parse_with_options(
1111 memory("consumer-input", case),
1112 &ParseOptions::default().format("matpower").unwrap(),
1113 )
1114 .expect("declared MATPOWER");
1115 let source = declared.source().expect("source retained");
1116 assert_eq!(source.name(), "consumer-input");
1117 assert_eq!(source.format().map(FormatId::as_str), Some("matpower"));
1118 }
1119
1120 #[test]
1121 fn universal_parse_reads_declared_iso_8859_1_xiidm_and_retains_exact_bytes() {
1122 let text = r#"<?xml version="1.0" encoding="ISO-8859-1"?>
1123<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">
1124 <iidm:voltageLevel id="VL" nominalV="225" topologyKind="BUS_BREAKER">
1125 <iidm:busBreakerTopology><iidm:bus id="B" v="225" angle="0"/></iidm:busBreakerTopology>
1126 <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>
1127 </iidm:voltageLevel>
1128</iidm:network>"#;
1129 let bytes: Vec<u8> = text
1130 .chars()
1131 .map(|value| u8::try_from(u32::from(value)).expect("fixture is ISO-8859-1"))
1132 .collect();
1133 assert!(std::str::from_utf8(&bytes).is_err());
1134
1135 for (name, format) in [
1136 ("case.xiidm", None),
1137 ("case.xml", None),
1138 ("memory", Some("xiidm")),
1139 ] {
1140 let source = Source::from_memory(name, bytes.clone()).unwrap();
1141 let module = super::parse_with_options(source, &options(format)).unwrap();
1142 let PioValue::BalancedNetwork(network) = &module.value() else {
1143 panic!(
1144 "expected BalancedNetwork, got {}",
1145 module.value().type_name()
1146 );
1147 };
1148 assert_eq!(
1149 network.case_metadata().source_model_format.as_deref(),
1150 Some("Réseau PowSybl")
1151 );
1152 let retained = module.source().unwrap();
1153 assert_eq!(retained.format().map(FormatId::as_str), Some("xiidm"));
1154 assert_eq!(retained.primary_buffer().unwrap().bytes(), bytes);
1155
1156 let emitted =
1157 emit(&module, "xiidm", Destination::memory("copy.xiidm").unwrap()).unwrap();
1158 assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1159 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1160 panic!("memory destination returned a path output");
1161 };
1162 assert_eq!(artifacts.len(), 1);
1163 assert_eq!(artifacts[0].bytes(), bytes);
1164 }
1165 }
1166
1167 #[test]
1168 fn a_dss_source_parses_to_a_multiconductor_network() {
1169 let module = parse(memory(
1170 "feeder.dss",
1171 "New Circuit.c basekv=12.47 bus1=src\n",
1172 ))
1173 .expect("dss parses");
1174 let PioValue::MulticonductorNetwork(network) = &module.value() else {
1175 panic!(
1176 "expected multiconductor network, got {}",
1177 module.value().type_name()
1178 );
1179 };
1180 assert_eq!(network.name().as_deref(), Some("c"));
1181 }
1182
1183 #[test]
1184 fn a_declared_distribution_format_routes_without_an_extension() {
1185 let module = parse(
1186 memory("<memory>", "New Circuit.c basekv=12.47 bus1=src\n")
1187 .with_format(powerio_core::FormatId::new("dss").unwrap()),
1188 )
1189 .expect("declared dss parses");
1190 assert_value_type(&module, "powerio.MulticonductorNetwork");
1191 }
1192
1193 #[test]
1194 fn json_routes_by_top_level_markers() {
1195 let module = parse(memory(
1197 "feeder.json",
1198 r#"{"data_model": "ENGINEERING", "bus": {}}"#,
1199 ))
1200 .expect("pmd parses");
1201 assert_value_type(&module, "powerio.MulticonductorNetwork");
1202 }
1203
1204 #[test]
1205 fn a_bare_network_object_is_not_powerio_ir_or_a_case_format() {
1206 let error = parse(memory(
1207 "net.json",
1208 r#"{"name":"network","base_mva":100.0,"buses":[],"branches":[]}"#,
1209 ))
1210 .expect_err("an unmarked network object must not parse");
1211 assert!(error.to_string().contains("cannot infer JSON format"));
1212 }
1213
1214 #[test]
1215 fn the_error_path_retains_the_source() {
1216 let error = parse(memory("case.m", "not matpower at all")).expect_err("malformed");
1217 assert!(error.retained_source().is_some());
1218 }
1219
1220 fn fixture(path: &str) -> String {
1221 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1222 std::fs::read_to_string(root.join(path)).unwrap()
1223 }
1224
1225 #[test]
1226 fn goc3_parses_to_an_scuc_instance() {
1227 let text = fixture("../powerio-prob/tests/data/goc3_small.json");
1230 let module = parse(memory("goc3_small.json", &text)).expect("goc3 parses");
1231 assert_value_type(&module, "powerio.AcScucInstance");
1232 assert!(module.source().is_some());
1233 let PioValue::AcScucInstance(instance) = &module.value() else {
1234 unreachable!();
1235 };
1236 assert_eq!(instance.network().buses().len(), 2);
1237 }
1238
1239 #[test]
1240 fn goc3_problem_and_solution_parse_with_the_one_public_operation() {
1241 let problem = fixture("../tests/data/goc3/goc3_small.json");
1242 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1243 let source = powerio_core::Source::from_memory("problem.json", problem.into_bytes())
1244 .unwrap()
1245 .with_named_buffer("solution.json", solution.into_bytes())
1246 .unwrap();
1247 let module = parse(source).expect("problem and solution parse together");
1248 let PioValue::AcScucSolution(solution) = &module.value() else {
1249 panic!(
1250 "expected AC SCUC solution, got {}",
1251 module.value().type_name()
1252 );
1253 };
1254 assert_eq!(solution.instance().network().buses().len(), 2);
1255 assert_eq!(
1256 solution.network_outputs().shunt_step,
1257 vec![vec![1], vec![2]]
1258 );
1259 assert_eq!(module.sources().len(), 2);
1260
1261 let emitted = emit(
1262 &module,
1263 "goc3-json",
1264 Destination::memory("solution.json").unwrap(),
1265 )
1266 .expect("solution emits as official GOC3 output");
1267 let EmittedOutput::Memory { artifacts } = emitted.output() else {
1268 unreachable!();
1269 };
1270 assert_eq!(artifacts.len(), 1);
1271 let document: serde_json::Value = serde_json::from_slice(artifacts[0].bytes()).unwrap();
1272 assert!(document.get("time_series_output").is_some());
1273 assert!(document.get("network").is_none());
1274 }
1275
1276 #[test]
1277 fn goc3_solution_alone_names_the_missing_problem() {
1278 let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1279 let error = parse(memory("solution.json", &solution))
1280 .expect_err("a solution without its problem is incomplete");
1281 assert!(error.to_string().contains("matching problem file"));
1282 assert!(error.retained_source().is_some());
1283 }
1284
1285 #[test]
1286 fn opfdata_parses_to_an_ac_opf_solution() {
1287 let text = fixture("../tests/data/opfdataset/example_0.json");
1288 let module = parse(memory("example_0.json", &text)).expect("opfdata parses");
1289 let PioValue::AcOpfSolution(solution) = &module.value() else {
1290 panic!(
1291 "expected AC OPF solution, got {}",
1292 module.value().type_name()
1293 );
1294 };
1295 assert_eq!(
1296 module
1297 .sources()
1298 .first()
1299 .and_then(|source| source.format())
1300 .map(powerio_core::FormatId::as_str),
1301 Some("opfdata-json")
1302 );
1303 assert_eq!(
1304 *solution.termination(),
1305 powerio_prob::Termination::NotReported
1306 );
1307 assert!((solution.objective() - 2_265.953_939_003_096).abs() < 1e-9);
1308
1309 let instance = solution.instance();
1310 assert_eq!(instance.network().buses().len(), 14);
1311 assert_eq!(instance.network().generators().len(), 5);
1312 let initial = instance.initial_point().expect("OPFData includes initials");
1313 let generator_id = instance.network().generators()[0]
1314 .uid
1315 .as_deref()
1316 .expect("parsed generators have stable identities");
1317 assert!((initial.generator_active_power(generator_id).unwrap() - 170.0).abs() < 1e-9);
1318 assert!((initial.generator_voltage_setpoint(generator_id).unwrap() - 1.0).abs() < 1e-12);
1319 assert!(solution.residuals().max_active_power_mismatch.unwrap() < 1.0);
1320 assert!(solution.residuals().max_reactive_power_mismatch.unwrap() < 1.0);
1321 }
1322
1323 #[test]
1324 fn malformed_opfdata_uses_the_universal_parse_error_path() {
1325 let error = parse(
1326 memory("broken.json", "{\"grid\": {}}")
1327 .with_format(powerio_core::FormatId::new("opfdata-json").unwrap()),
1328 )
1329 .expect_err("malformed OPFData");
1330 assert!(error.retained_source().is_some());
1331 }
1332
1333 const BMOPF_TINY: &str = r#"{
1334 "bus": {"a": {"terminal_names": ["1", "2", "3", "n"],
1335 "perfectly_grounded_terminals": ["n"]}},
1336 "voltage_source": {"s": {"bus": "a", "terminal_map": ["1", "2", "3"],
1337 "v_magnitude": [240.0, 240.0, 240.0], "v_angle": [0.0, -2.0944, 2.0944]}}
1338 }"#;
1339
1340 #[test]
1341 fn bmopf_parses_to_a_multiconductor_network() {
1342 let module = parse(memory("feeder.json", BMOPF_TINY)).expect("sniffed bmopf parses");
1345 assert_value_type(&module, "powerio.MulticonductorNetwork");
1346
1347 let module = parse(
1348 memory("<memory>", BMOPF_TINY)
1349 .with_format(powerio_core::FormatId::new("bmopf-json").unwrap()),
1350 )
1351 .expect("declared bmopf parses");
1352 assert_value_type(&module, "powerio.MulticonductorNetwork");
1353 }
1354
1355 #[test]
1356 fn nameless_json_text_routes_by_content() {
1357 let goc3 = fixture("../powerio-prob/tests/data/goc3_small.json");
1361 let module = parse(memory("<memory>", &goc3)).expect("nameless goc3 parses");
1362 assert_value_type(&module, "powerio.AcScucInstance");
1363
1364 let module = parse(memory("<memory>", BMOPF_TINY)).expect("nameless bmopf parses");
1365 assert_value_type(&module, "powerio.MulticonductorNetwork");
1366 }
1367
1368 #[test]
1369 fn a_declared_problem_format_that_fails_retains_the_source() {
1370 let error = parse(
1371 memory("broken.json", "{\"network\": {}}")
1372 .with_format(powerio_core::FormatId::new("goc3-json").unwrap()),
1373 )
1374 .expect_err("malformed goc3");
1375 assert!(error.retained_source().is_some());
1376 }
1377
1378 const PYPSA_STATIC: [(&str, &str); 4] = [
1379 ("network.csv", "name\nseq\n"),
1380 ("buses.csv", "name,v_nom\nB1,138.0\nB2,138.0\n"),
1381 ("loads.csv", "name,bus,p_set,q_set\nL1,B2,5.0,1.0\n"),
1382 (
1383 "generators.csv",
1384 "name,bus,control,p_nom,p_set\nG1,B1,Slack,100.0,12.0\n",
1385 ),
1386 ];
1387
1388 fn pypsa_folder(extra: &[(&str, &str)]) -> tempfile::TempDir {
1389 let temp = tempfile::tempdir().unwrap();
1390 for (name, content) in PYPSA_STATIC.iter().chain(extra) {
1391 std::fs::write(temp.path().join(name), content).unwrap();
1392 }
1393 temp
1394 }
1395
1396 #[test]
1397 fn a_pypsa_snapshot_parses_to_a_balanced_network() {
1398 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n")]);
1399 let module =
1400 parse(powerio_core::Source::open(dir.path()).unwrap()).expect("snapshot parses");
1401 assert_value_type(&module, "powerio.BalancedNetwork");
1402 }
1403
1404 #[test]
1405 fn a_pypsa_input_series_parses_to_a_network_time_series() {
1406 let dir = pypsa_folder(&[
1407 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1408 ("loads-p_set.csv", "snapshot,L1\nnow,10.0\nlater,20.0\n"),
1409 ]);
1410 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1411 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1412 assert!(module.source().is_some());
1413 let PioValue::TimeSeries(series) = &module.value() else {
1414 unreachable!();
1415 };
1416 assert_eq!(series.len(), 2);
1417 let PioValue::BalancedNetwork(later) = series.get(1).unwrap() else {
1418 unreachable!();
1419 };
1420 assert!((later.loads()[0].p - 20.0).abs() < 1e-12);
1421 }
1422
1423 #[test]
1424 fn a_pypsa_voltage_series_parses_to_operating_points() {
1425 let dir = pypsa_folder(&[
1426 ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1427 (
1428 "buses-v_mag_pu.csv",
1429 "snapshot,B1,B2\nnow,1.0,0.99\nlater,1.0,0.97\n",
1430 ),
1431 (
1432 "buses-v_ang.csv",
1433 "snapshot,B1,B2\nnow,0.0,-0.017453292519943295\nlater,0.0,-0.03490658503988659\n",
1434 ),
1435 ]);
1436 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1437 assert_value_type(
1438 &module,
1439 "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>",
1440 );
1441 let PioValue::TimeSeries(series) = &module.value() else {
1442 unreachable!();
1443 };
1444 let PioValue::BalancedOperatingPoint(later) = series.get(1).unwrap() else {
1445 unreachable!();
1446 };
1447 assert!((later.bus_voltage_magnitude(powerio_tx::BusId(2)).unwrap() - 0.97).abs() < 1e-12);
1448 }
1449
1450 #[test]
1451 fn a_pypsa_axis_with_no_series_stays_a_network_time_series() {
1452 let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n1,later\n")]);
1456 let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("axis parses");
1457 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1458 }
1459
1460 const EGRET_SERIES: &str = r#"{
1461 "model_name": "uc2",
1462 "elements": {
1463 "bus": {"1": {"matpower_bustype": "ref", "base_kv": 138.0},
1464 "2": {"matpower_bustype": "PQ", "base_kv": 138.0}},
1465 "load": {"load_1": {"bus": "2",
1466 "p_load": {"data_type": "time_series", "values": [10.0, 20.0]},
1467 "q_load": 3.0}},
1468 "generator": {"1": {"bus": "1", "pg": 12.0, "qg": 0.0,
1469 "p_min": 0.0, "p_max": 50.0, "q_min": -10.0, "q_max": 10.0}},
1470 "branch": {"1": {"from_bus": "1", "to_bus": "2",
1471 "resistance": 0.01, "reactance": 0.1, "charging_susceptance": 0.0,
1472 "rating_long_term": 100.0, "rating_short_term": 100.0,
1473 "rating_emergency": 100.0, "transformer_phase_shift": 0.0}}
1474 },
1475 "system": {"baseMVA": 100.0, "time_keys": ["t1", "t2"]}
1476 }"#;
1477
1478 #[test]
1479 fn egret_time_keys_parse_to_a_network_time_series() {
1480 let module = parse(
1481 memory("uc2.json", EGRET_SERIES)
1482 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1483 )
1484 .expect("egret series parses");
1485 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1486 assert!(module.source().is_some());
1487
1488 let module = parse(memory("uc2.json", EGRET_SERIES)).expect("sniffed egret parses");
1490 assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1491 }
1492
1493 #[cfg(feature = "gridfm")]
1494 #[test]
1495 fn powerio_ir_uses_deserialize_not_parse() {
1496 use powerio_tx::{Bus, BusId, BusType};
1497 let network = powerio_tx::BalancedNetwork::in_memory(
1498 "stored",
1499 100.0,
1500 vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1501 vec![],
1502 );
1503 let original = powerio_core::PioModule::new(PioValue::BalancedNetwork(network));
1504 let emitted = serialize(&original, Destination::memory("case.pio.json").unwrap())
1505 .expect("module serializes");
1506 let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1507 unreachable!();
1508 };
1509 let module = deserialize(
1510 Source::from_memory("case.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1511 )
1512 .expect("module deserializes");
1513 assert_value_type(&module, "powerio.BalancedNetwork");
1514 assert!(module.source().is_some());
1515 }
1516
1517 #[cfg(feature = "gridfm")]
1518 #[test]
1519 fn a_gridfm_dataset_parses_to_a_scenario_set() {
1520 let case = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
1523 let base = powerio_tx::parse(powerio_core::Source::open(case).unwrap())
1524 .expect("case9 parses")
1525 .into_value();
1526 let mut varied = base.clone();
1527 varied.loads_mut()[0].p += 5.0;
1528 let out = tempfile::tempdir().unwrap();
1529 let snapshots = [
1530 powerio_matrix::GridfmSnapshot::new(&base, 0),
1531 powerio_matrix::GridfmSnapshot::new(&varied, 1),
1532 ];
1533 powerio_matrix::emit_gridfm_batch(
1534 &snapshots,
1535 out.path(),
1536 &powerio_matrix::GridfmOptions::default(),
1537 )
1538 .expect("dataset writes");
1539
1540 let module =
1541 parse(powerio_core::Source::open(out.path()).unwrap()).expect("dataset parses");
1542 assert_value_type(&module, "powerio.ScenarioSet<powerio.BalancedNetwork>");
1543 assert!(module.source().is_some());
1544 let PioValue::ScenarioSet(set) = &module.value() else {
1545 unreachable!();
1546 };
1547 assert_eq!(set.len(), 2);
1548 assert!(set.get("0").is_some());
1549 assert!(set.get("1").is_some());
1550 }
1551
1552 #[test]
1553 fn an_unrecognized_directory_is_refused_with_the_hub_wording() {
1554 let dir = tempfile::tempdir().unwrap();
1555 std::fs::write(dir.path().join("notes.txt"), "not a case").unwrap();
1556 let error =
1557 parse(powerio_core::Source::open(dir.path()).unwrap()).expect_err("refused directory");
1558 assert!(error.to_string().contains("directory"), "{error}");
1559 }
1560
1561 #[test]
1562 fn a_scalar_egret_document_stays_a_balanced_network() {
1563 let scalar = EGRET_SERIES
1564 .replace(r#", "time_keys": ["t1", "t2"]"#, "")
1565 .replace(
1566 r#"{"data_type": "time_series", "values": [10.0, 20.0]}"#,
1567 "10.0",
1568 );
1569 let module = parse(
1570 memory("uc2.json", &scalar)
1571 .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1572 )
1573 .expect("scalar egret parses");
1574 assert_value_type(&module, "powerio.BalancedNetwork");
1575 }
1576}