Skip to main content

powerio/
lib.rs

1//! PowerIO: compiler infrastructure for power system data.
2//!
3//! The short `powerio` name is the entry facade over the component crates:
4//! `powerio-core` (sources, diagnostics, errors, modules), `powerio-tx`
5//! (the balanced transmission model and its format parsing and emission),
6//! `powerio-dist` (the multiconductor distribution model), and `powerio-prob`
7//! (operating points, problem instances, and solutions). The facade owns the
8//! dynamic value boundary: [`PioValue`], [`parse`], [`emit`], [`serialize`],
9//! and [`deserialize`].
10//!
11//! [`parse`] compiles one input into `PioModule<PioValue>`, routing to
12//! whichever built in family claims it. Inspect the value with ordinary enum
13//! matching and emit the module without discarding its input or diagnostics:
14//!
15//! ```no_run
16//! let module = powerio::parse("case9.m")?;
17//! match module.value() {
18//!     powerio::PioValue::BalancedNetwork(network) => {
19//!         println!("{} buses", network.buses().len());
20//!     }
21//!     other => println!("parsed {}", other.type_name()),
22//! }
23//! powerio::emit(&module, "matpower", "copy.m")?;
24//! # Ok::<(), Box<dyn std::error::Error>>(())
25//! ```
26//!
27//! A file name, a directory name, and content already in memory all reach the
28//! same operation, so it does not multiply into name-, text-, and
29//! byte-specific verbs:
30//!
31//! ```no_run
32//! let from_file = powerio::parse("case9.m")?;
33//! let from_directory = powerio::parse("pypsa_case/")?;
34//! let from_memory = powerio::parse(std::fs::read("case9.egret.json")?)?;
35//! # Ok::<(), Box<dyn std::error::Error>>(())
36//! ```
37//!
38//! Content in memory carries the name `<memory>`, which identifies no format,
39//! so a format detected from a file extension rather than from the document
40//! itself is either declared or named:
41//!
42//! ```no_run
43//! let declared = powerio::parse_with_options(
44//!     std::fs::read("case9.m")?,
45//!     &powerio::ParseOptions::default().format("matpower")?,
46//! )?;
47//! let named = powerio::parse(powerio::Source::from_memory(
48//!     "case9.m",
49//!     std::fs::read("case9.m")?,
50//! )?)?;
51//! # Ok::<(), Box<dyn std::error::Error>>(())
52//! ```
53//!
54//! A geographic layer is a value like any other: the canonical `.geo.json`,
55//! GeoJSON, aliased CSV or JSON records, headerless buscoords CSV, and a
56//! PowerWorld `.pwd` display all parse to [`PioValue::GeoLayer`], `emit`
57//! writes one as `geo-json`, and [`apply_geo_layer`] places one onto a case.
58//!
59//! The three files a PSS/E contingency analysis reads are values of the same
60//! kind. A `.con` parses to [`PioValue::ContingencySet`], a `.sub` to
61//! [`PioValue::SubsystemSet`], and a `.mon` to [`PioValue::MonitoredSet`];
62//! `emit` writes each back under its own token, and `ContingencySet::resolve`,
63//! `Subsystem::select_buses`, and `MonitoredSet::resolve` bind one to a
64//! network.
65//!
66//! [`parse_with_options`] selects the parser explicitly and widens the
67//! directory a format may refer to further files beneath. [`Source`] and
68//! [`Destination`] remain the advanced input and output: a source carrying
69//! named buffers for a multi-file case in memory, and a memory destination
70//! with its artifact root name.
71//!
72//! ```no_run
73//! let module = powerio::parse_with_options(
74//!     "case.data",
75//!     &powerio::ParseOptions::default().format("psse")?,
76//! )?;
77//! # Ok::<(), Box<dyn std::error::Error>>(())
78//! ```
79
80/// The facade version recorded on producers and stored modules.
81pub const VERSION: &str = env!("CARGO_PKG_VERSION");
82
83/// The `schema` discriminator of every PowerIO IR document.
84pub const IR_SCHEMA_NAME: &str = "pio-ir";
85
86/// The PowerIO IR version this build writes.
87///
88/// The IR version is an integer that advances only when the serialized
89/// representation changes incompatibly for an existing value type. It is
90/// independent of the PowerIO release, which the `producer` record of a
91/// document names, and of the C ABI version.
92///
93/// | IR version | First release | Change |
94/// |---|---|---|
95/// | 1 | v0.10.0 | the `PioModule` serialization, under the identity `powerio.module` |
96/// | 2 | v0.11.0 | the identity `pio-ir`; the producer release recorded apart from the IR version; retained source bytes left out |
97///
98/// Additive structural types keep the IR version unchanged. A reader accepts
99/// the types it implements; an unknown type requires a newer reader.
100/// [`IR_MIN_VERSION`] is the oldest IR version this build reads.
101pub const IR_VERSION: u64 = 2;
102
103/// The oldest PowerIO IR version this build reads.
104///
105/// The floor rises only at a minor release boundary. PowerIO 0.11 writes and
106/// reads IR version 2.
107pub const IR_MIN_VERSION: u64 = 2;
108
109/// The `$id` of the schema snapshot describing this build's structural types.
110/// The release in the path identifies the catalog, not a new IR version.
111pub 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};
132/// Balanced network records and the public network and geographic submodules.
133/// Derived indexes, normalization data, solver tables, and component error
134/// types remain available from `powerio-tx` rather than being duplicated at
135/// the facade root.
136pub use powerio_tx::{contingency, geo, network, version};
137
138pub use powerio_core::diagnostic_codes;
139/// The common module records and containers. These explicit facade exports
140/// keep ordinary callers out of the component crate paths.
141pub 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
150/// The facade error covers source acquisition, routing, stored modules, and
151/// component failures converted at their boundary.
152pub use powerio_core::Error;
153pub type Result<T> = std::result::Result<T, powerio_core::Error>;
154
155/// Distribution types remain grouped under `powerio::dist` where their names
156/// overlap with balanced network types. Common unambiguous records are also
157/// available at the facade root.
158pub 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};
165/// The calculation types used by solver consumers. The full problem
166/// vocabulary lives in [`powerio_prob`]; these types sit at the facade root so
167/// a consumer does not need a second PowerIO dependency to name its boundary.
168pub 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/// Matrix and graph data, re-exported from `powerio-matrix` under the
184/// `matrix` feature. Matrix construction is never a parse result, so the
185/// facade's automatic parsing and [`PioValue`] do not change with this
186/// feature.
187#[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/// The GO Challenge 3 root keys, read without building a document tree.
255#[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
277/// The GO Challenge 3 root keys of a JSON document, or `None` for a well
278/// formed document whose root is not an object.
279fn 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        // `entry_names` succeeds here only for an in-memory source with named
324        // buffers. A file source never searches sibling files.
325        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
359/// Transform the `Substation` table in PowerWorld AUX text into a geographic
360/// layer without exposing the component parser's borrowed `AuxFile` type.
361///
362/// Rows without a finite number, latitude, and longitude are skipped. A valid
363/// AUX document with no usable substation coordinates returns an empty layer.
364///
365/// # Errors
366/// The AUX section syntax is malformed.
367pub 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
373/// A possibly partial assignment of instantaneous operating quantities over
374/// one network's fixed equipment identities.
375pub use powerio_prob::OperatingPoint;
376mod value;
377pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
378
379/// Optional configuration for [`parse_with_options`]. Every field defaults to
380/// inference, so [`ParseOptions::default`] is what [`parse`] uses.
381#[derive(Clone, Debug, Default)]
382#[non_exhaustive]
383pub struct ParseOptions {
384    /// The parser selected by its stable format token rather than inferred
385    /// from the input's name and content.
386    pub format: Option<powerio_core::FormatId>,
387    /// The directory beneath which a format may refer to further files,
388    /// widening the default of the input file's own directory.
389    pub acquisition_root: Option<std::path::PathBuf>,
390}
391
392impl ParseOptions {
393    /// Select the parser by its stable format token.
394    ///
395    /// # Errors
396    /// `REQUEST.FORMAT.INVALID_ID` when the token is not a format identifier.
397    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    /// Select the parser by an already validated format identity.
403    #[must_use]
404    pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
405        self.format = Some(format);
406        self
407    }
408
409    /// Permit acquisition of files a format refers to beneath `root`.
410    #[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
417/// Parse one source into a compiled module of whichever built in family
418/// claims it. Balanced network formats produce
419/// [`PioValue::BalancedNetwork`]; network only distribution formats (OpenDSS
420/// `.dss`, PMD ENGINEERING JSON, and BMOPF JSON) produce
421/// [`PioValue::MulticonductorNetwork`]. A source that defines a particular
422/// calculation produces that calculation's value. One DOE GO Challenge 3
423/// problem data file produces [`PioValue::AcScucInstance`]. One source that
424/// contains a problem data file and its matching solution data file produces
425/// [`PioValue::AcScucSolution`]; a solution data file alone is rejected because
426/// its row identities and time axis come from the problem. DeepMind OPFData
427/// JSON, which explicitly represents a solved AC OPF, produces
428/// [`PioValue::AcOpfSolution`]. The parser's findings are the module's
429/// diagnostics, and the module keeps the original input, so writing the same
430/// format again returns the original file content.
431///
432/// The input is a file or directory name, content already in memory, or a
433/// [`powerio_core::Source`] carrying named buffers or a widened acquisition
434/// root. [`parse_with_options`] selects the parser explicitly. Content in
435/// memory carries the name [`powerio_core::MEMORY_SOURCE_NAME`], which
436/// identifies no format, so a format detected from a file extension rather
437/// than from the document itself is either declared through the options or
438/// named through [`powerio_core::Source::from_memory`].
439///
440/// The family comes from the input's declared format when one was selected,
441/// and otherwise from the name and content: a `.dss` extension routes to the
442/// distribution parser, a `.json` document routes by its top level markers
443/// ([`format::routing::classify_json_text`]), a name with no recognized
444/// extension whose content opens a JSON document (an in-memory source has no
445/// extension) routes the same way, and every other name routes to
446/// the balanced network hub, whose own detection and refusals apply.
447///
448/// PowerIO IR is not a grid exchange format: [`parse`] refuses it and
449/// [`deserialize`] reads the current PowerIO IR document.
450///
451/// # Errors
452/// The routed family's failure, carrying its findings and the retained
453/// source.
454pub 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
460/// Parse one input under `options`, which selects the parser explicitly or
461/// widens the directory a format may refer to further files beneath.
462/// [`parse`] is this operation with the default options.
463///
464/// # Errors
465/// The input cannot be acquired, the format cannot be selected, or the routed
466/// family fails, each carrying its own diagnostic code.
467pub 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
502/// Parse the official GO Challenge 3 problem file, or a problem and its
503/// matching solution file supplied by one directory or one memory source.
504/// File roles come from the required top level JSON fields, not filenames.
505/// A solution file alone is incomplete because it contains neither the
506/// component definitions nor the time axis.
507fn 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
549/// Read one standalone geographic document into [`PioValue::GeoLayer`]. A
550/// PowerWorld `.pwd` display lifts into a diagram space layer with substation
551/// targets; every other supported document is already a layer. The reader's
552/// notes on records it could not use become the module's diagnostics.
553fn 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
613/// Read one PSS/E contingency analysis file into its own value. The reader
614/// keeps a statement outside its grammar as the original line and reports it;
615/// those notes become the module's diagnostics, so nothing the file states is
616/// lost between the text and the value.
617fn 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
658/// PyPSA CSV dispatch: one snapshot with no series siblings is the scalar
659/// profile through the balanced hub; a declared axis routes to the sequence
660/// parser, producing a network series or, when only operating quantities
661/// vary, an operating point series over one shared network.
662fn parse_pypsa(
663    source: powerio_core::Source,
664) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
665    if !source.is_directory() {
666        // A file claiming the PyPSA token gets the hub's own refusal wording.
667        return format::parse(source).map(|module| module.map_value(PioValue::from));
668    }
669    // Directory routing identified the source before the typed reader runs.
670    // Record that decision on an undeclared source so same format emission
671    // can distinguish a PyPSA directory from the GridFM directory family.
672    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/// gridfm dispatch: every scenario of the Parquet dataset as one scenario
707/// set over shared element identities.
708#[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        // A file claiming the gridfm token gets the hub's own refusal wording.
714        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
728/// Egret dispatch: a document declaring `system.time_keys` routes to the
729/// sequence parser and produces a balanced network time series; a scalar
730/// document routes through the balanced hub.
731fn 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
765/// The family a source routes to. The balanced hub is the default: it owns
766/// the guidance for unknown names and refused shapes. `Balanced` carries the
767/// JSON classification when routing here was itself the result of one (so
768/// the balanced hub does not classify the same text a second time); `None`
769/// when the source routed here by extension, by a declared token, or by a
770/// directory shape, none of which run a JSON classification at all.
771enum RoutedFamily {
772    Balanced(Option<format::routing::JsonClass>),
773    Distribution(Option<format::routing::DistributionFormat>),
774    Goc3,
775    OpfData,
776    PypsaDirectory,
777    Egret,
778    /// A standalone geographic document: the canonical `.geo.json`, GeoJSON,
779    /// aliased CSV or JSON records, headerless buscoords CSV, or a PowerWorld
780    /// `.pwd` display lifted into a diagram space layer.
781    Geo,
782    /// One of the three text files a PSS/E contingency analysis reads.
783    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        // GOC3 pairs are identified by their official JSON roots rather than
796        // filenames. The format parser performs the exact cardinality and
797        // schema checks after routing.
798        if directory_has_goc3_data(source) {
799            return Ok(RoutedFamily::Goc3);
800        }
801        // PyPSA is a CSV folder containing network.csv. GridFM is a Parquet
802        // dataset with bus_data.parquet at one of its documented locations.
803        // Anything else falls to the balanced hub's refusal.
804        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        // Extensions with dedicated non-JSON readers keep them; anything
834        // else (a nameless in-memory source above all) can still carry a
835        // JSON document, so content that opens one routes by classification,
836        // mirroring the balanced hub's own sniff.
837        "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                    // Strip a UTF-8 BOM the way the JSON classifier does, so
846                    // a BOM-prefixed nameless document routes the same as
847                    // the identical content saved with a .json name.
848                    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
862/// Whether `name` carries the compound `geo.json` extension: the whole name,
863/// or the name after a separator, so `layer.geo.json`, `layer_geo.json`, and
864/// `layer-geo.json` all state a layer. A stem that merely ends in the same
865/// letters (`apogeo.json`) does not, and keeps its JSON classification, which
866/// matters because JSON content classification has no layer verdict and would
867/// refuse the file as an unrecognized case.
868fn 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
877/// Whether `token` names the standalone geographic layer document.
878pub(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
885/// Whether `token` names a PowerWorld display file, which reads as a diagram
886/// space layer.
887pub(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
894/// Whether `token` names a document that reads into [`PioValue::GeoLayer`].
895fn is_geo_token(token: &str) -> bool {
896    is_geo_layer_token(token) || is_pwd_display_token(token)
897}
898
899/// Which of the three PSS/E contingency analysis files a source holds. Each
900/// is its own format token, its own value type, and its own writer; the three
901/// never cross.
902#[derive(Clone, Copy, Debug, Eq, PartialEq)]
903pub(crate) enum ContingencyFile {
904    /// The contingency description file, `.con`.
905    Con,
906    /// The subsystem description file, `.sub`.
907    Sub,
908    /// The monitored element file, `.mon`.
909    Mon,
910}
911
912impl ContingencyFile {
913    /// The canonical format token.
914    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    /// The conventional filename suffix, without a leading dot.
923    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    /// The name of the one artifact an emission writes. A path destination
932    /// uses the caller's own path; a memory destination uses its root name.
933    /// `con` alone is a reserved device name on Windows, so every name here
934    /// carries a stem.
935    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    /// The structural type name the file reads into.
944    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    /// The code for input that is not UTF-8 text.
953    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    /// How the refusal names the file, in prose.
963    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
972/// The PSS/E contingency analysis file a format token names, or `None` for
973/// every other token. Case, hyphens, and underscores do not distinguish
974/// spellings, so `psse-con`, `psse_con`, `PSSECon`, and `con` are one name.
975pub(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
984/// The family a JSON document's content markers select.
985fn 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    // Family routing needs decoded text; a non-UTF-8 `.json` fails in
992    // the balanced hub with its own wording. Classification never ran, so
993    // the balanced hub gets no hint and classifies it itself.
994    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        // The balanced hub owns the refusal wording for unrecognized or
1016        // ambiguous documents. Pass the classification through so it does not
1017        // inspect the same bytes twice.
1018        JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
1019            Ok(RoutedFamily::Balanced(Some(class)))
1020        }
1021    }
1022}
1023
1024/// The family a declared format token selects. Unknown tokens fall to the
1025/// balanced hub, which owns the refusal wording and the accepted name list.
1026fn 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        // A PMD document carries `data_model`, which no balanced format does.
1196        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        // A calculation defining source produces its calculation's value,
1228        // never the bare network its data could also build.
1229        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        // BMOPF shares the multiconductor network model. Callers construct a
1343        // power flow or optimal power flow instance explicitly afterward.
1344        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        // An in-memory source has no extension, so the family comes
1358        // from the document's own markers. Calculation and distribution
1359        // formats dispatch the same way they would from a `.json` file.
1360        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        // Several declared snapshots and nothing varying preserve the axis
1453        // as networks sharing every table; nothing here is an operating
1454        // point series.
1455        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        // The sniffed route agrees with the declared one.
1489        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        // Write a two scenario dataset with the matrix writer, then parse the
1521        // directory through the universal parse.
1522        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}