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//! Native SINCAL projects are behind the `sincal` feature, on by default,
81//! which brings in SQLite through `powerio-tnd`. A build with
82//! `default-features = false` needs no C compiler: [`grid_formats`] then
83//! lists no SINCAL format, [`ParseOptions`] and [`EmitOptions`] carry no
84//! SINCAL field, and a SINCAL source or target fails with
85//! `REQUEST.SINCAL.FEATURE_DISABLED`. [`resolve_format`] still describes the
86//! SINCAL tokens, because format metadata is not a build probe.
87
88/// The facade version recorded on producers and stored modules.
89pub const VERSION: &str = env!("CARGO_PKG_VERSION");
90
91/// The `schema` discriminator of every PowerIO IR document.
92pub const IR_SCHEMA_NAME: &str = "pio-ir";
93
94/// The PowerIO IR version this build writes.
95///
96/// The IR version is an integer that advances only when the serialized
97/// representation changes incompatibly for an existing value type. It is
98/// independent of the PowerIO release, which the `producer` record of a
99/// document names, and of the C ABI version.
100///
101/// | IR version | First release | Change |
102/// |---|---|---|
103/// | 1 | v0.10.0 | the `PioModule` serialization, under the identity `powerio.module` |
104/// | 2 | v0.11.0 | the identity `pio-ir`; the producer release recorded apart from the IR version; retained source bytes left out |
105///
106/// Additive structural types keep the IR version unchanged. A reader accepts
107/// the types it implements; an unknown type requires a newer reader.
108/// [`IR_MIN_VERSION`] is the oldest IR version this build reads.
109pub const IR_VERSION: u64 = 2;
110
111/// The oldest PowerIO IR version this build reads.
112///
113/// The floor rises only at a minor release boundary. PowerIO 0.11 writes and
114/// reads IR version 2.
115pub const IR_MIN_VERSION: u64 = 2;
116
117/// The `$id` of the schema snapshot describing this build's structural types.
118/// The release in the path identifies the catalog, not a new IR version.
119pub const IR_SCHEMA_ID: &str = "https://powerio.dev/schema/pio-ir/2/0.11.7/schema.json";
120
121/// Loss-preserving PSS/E transient-stability parsing and explicit study pairing.
122pub use powerio_tx::dynamic::{
123    DynamicCase, DynamicRecord, DynamicStudy, DyrError, UnresolvedBusReference, parse_dyr,
124    parse_dyr_bytes,
125};
126
127use powerio_tx::format;
128pub use powerio_tx::{
129    Area, AutomaticOrder, AutomaticSpec, AutomaticTarget, BalancedNetwork, Branch, BranchCharging,
130    BranchCurrentRatings, BranchRatingSet, BranchRef, BranchSolution, BranchSusceptanceFormula,
131    Bus, BusId, BusSubset, BusType, Canvas, Change, ChangeOp, ChangeUnit, ContingencyAction,
132    ContingencyCase, ContingencyParsed, ContingencyResolution, ContingencySet, CoordinateSpace,
133    CoordsKind, DEFAULT_BASE_FREQUENCY, Detection, ElementKey, Expanded, Extras, GenCaps, GenCost,
134    Generator, GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoMeta, GeoParsed, GeoTarget,
135    Hvdc, Impedance, IndexCore, IndexedNetwork, InterfaceMember, JSON_CLASSES, JoinName, JsonClass,
136    Load, LoadVoltageModel, Location, MonitorScope, MonitorStatement, MonitoredParsed,
137    MonitoredResolution, MonitoredSet, PsseEquipmentIndex, PwdDisplay, PwdSubstation, ResolvedCase,
138    ResolvedComponent, ResolvedInterface, ResolvedVoltageScope, RetainedStatement, Selector,
139    SelectorGroup, SharedPsseEquipmentIndex, Shunt, ShuntBlock, SkipRule, SolverParams,
140    SourceFormat, Storage, Subsystem, SubsystemParsed, SubsystemSelector, SubsystemSet, Switch,
141    SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
142    TransformerControlMode, UnresolvedAction, UnresolvedMonitor, UnresolvedMonitorReason,
143    UnresolvedReason, Winding, apply_substation_points, calc_series_admittance_of,
144    classify_json_bytes, classify_json_text, repair_values, to_geo_layer_from_pwd,
145    to_lonlat_from_pwd_mercator,
146};
147/// Balanced network records and the public network and geographic submodules.
148/// Derived indexes, normalization data, solver tables, and component error
149/// types remain available from `powerio-tx` rather than being duplicated at
150/// the facade root.
151pub use powerio_tx::{contingency, dynamic, geo, network, version};
152
153pub use powerio_core::diagnostic_codes;
154/// The common module records and containers. These explicit facade exports
155/// keep ordinary callers out of the component crate paths.
156pub use powerio_core::{
157    ArtifactPath, ComponentId, Destination, Diagnostic, DiagnosticCode, DiagnosticId,
158    DiagnosticInfo, DiagnosticSeverity, DiagnosticStage, Digest, DigestAlgorithm, EmitResult,
159    EmittedOutput, Fidelity, FormatId, HistoryEntry, HistoryId, HistoryKind, MemoryArtifact,
160    OutputLayout, PioModule, Producer, Scenario, ScenarioId, ScenarioSet, Source, SourceBuffer,
161    SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan, StagedEdit, TimePoint,
162    TimeSeries,
163};
164
165/// The facade error covers source acquisition, routing, stored modules, and
166/// component failures converted at their boundary.
167pub use powerio_core::Error;
168pub type Result<T> = std::result::Result<T, powerio_core::Error>;
169
170/// Integrated transmission/distribution models, transformations, and adapters.
171pub use powerio_tnd as tnd;
172/// Distribution types and facade format dispatch.
173/// Types whose names overlap with balanced network types stay grouped here.
174pub mod dist {
175    pub use powerio_dist::*;
176    #[cfg(feature = "sincal")]
177    pub use powerio_tnd::formats::sincal::{SincalReadOptions, parse_sincal};
178    pub fn parse(
179        source: powerio_core::Source,
180    ) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error>
181    {
182        #[cfg(feature = "sincal")]
183        if source.format().is_some_and(|f| {
184            crate::format::routing::parse_distribution_format(f.as_str())
185                == Some(crate::format::routing::DistributionFormat::SincalMulticonductor)
186        }) {
187            return parse_sincal(source, &SincalReadOptions::default());
188        }
189        powerio_dist::parse(source)
190    }
191}
192pub use powerio_dist::{
193    BmopfEmitOptions, BmopfSchemaVersion, ConductorMatrix, DistComponentLocation, DistGeoMeta,
194    DistGeographicPoint, DistGraphEdgeKind, DistLocationTarget, MulticonductorNetwork,
195    NeutralKronOptions, NeutralKronReport,
196};
197
198pub use powerio_prob::solution::{SocwrOpfDuals, SocwrOpfSolution, SocwrOpfValues};
199/// The calculation types used by solver consumers. The full problem
200/// vocabulary lives in [`powerio_prob`]; these types sit at the facade root so
201/// a consumer does not need a second PowerIO dependency to name its boundary.
202pub use powerio_prob::{
203    AcBusSpecification, AcOpfInstance, AcOpfSolution, AcOtsInstance, AcOtsOptions, AcOtsSolution,
204    AcPfInstance, AcPfSolution, AcScucInstance, AcScucSolution, ActivePower, ActivePowerUnit,
205    ApparentPower, ApparentPowerUnit, BalancedCalculationInstance, BranchCurrentLimit,
206    BranchSelection, BranchSwitchingCost, CalculationUpdate, CurrentLimitPolicy,
207    DcBusSpecification, DcOpfInstance, DcOpfSolution, DcPfInstance, DcPfSolution, IslandingPolicy,
208    LinDist3FlowApplicability, LinDist3FlowApplicabilityStatus, LinDist3FlowBuildOptions,
209    LinDist3FlowLimitCheck, LinDist3FlowLimitKind, LinDist3FlowNode, LinDist3FlowOpfInstance,
210    LinDist3FlowOpfSolution, LinDist3FlowOpfValues, LinDist3FlowPfInstance, LinDist3FlowPfSolution,
211    LinDist3FlowPreparationAction, LinDist3FlowPreparationActionKind,
212    LinDist3FlowPreparationReport, LinDist3FlowReferencePolicy, LinDist3FlowReferenceProvenance,
213    LinDist3FlowReferenceState, LinDist3FlowTopology, LinDist3FlowUnsupported, LoadAllocation,
214    McAcOpfInstance, McAcOpfSolution, McAcPfInstance, McAcPfSolution, NetworkUpdate,
215    OperatingPointUpdate, ReactivePower, ReactivePowerUnit, Termination,
216    ThreeWindingTransformerTerminalActivePower, ThreeWindingTransformerTerminalPower, UpdateChange,
217    UpdateReport, UpdatedField, apply_bus_load_active_power, apply_updates,
218    evaluate_lindist3flow_pf_limits,
219};
220
221/// Matrix and graph data, re-exported from `powerio-matrix` under the
222/// `matrix` feature. Matrix construction is never a parse result, so the
223/// facade's automatic parsing and [`PioValue`] do not change with this
224/// feature.
225#[cfg(feature = "matrix")]
226pub use powerio_matrix as matrix;
227
228#[cfg(feature = "gridfm")]
229#[doc(hidden)]
230#[path = "gridfm.rs"]
231pub mod __gridfm;
232pub mod codes;
233mod formats;
234pub use formats::{FormatInfo, GridFormatInfo, NetworkFamily, grid_formats, resolve_format};
235#[cfg(feature = "gridfm")]
236mod collect;
237pub mod dist_geo;
238#[cfg(feature = "gridfm")]
239pub use __gridfm::codes as gridfm_codes;
240mod stored;
241mod write;
242pub use write::{EmitOptions, emit, emit_with_options};
243#[cfg(feature = "sincal")]
244mod sincal_write;
245#[cfg(feature = "sincal")]
246pub use sincal_write::{ArchiveOptions, SincalContainer, SincalExperimentalOptions};
247mod ir;
248#[cfg(feature = "schema")]
249pub use ir::generate_ir_schema;
250pub use ir::{deserialize, serialize, serialize_diagnostics};
251pub mod transform;
252pub use transform::{
253    ac_ots_instance_from_base, ac_ots_solution, apply_geo_layer, fix_ac_ots_topology,
254    materialize_ac_ots_solution, network_with_operating_point, neutral_kron,
255    neutral_kron_with_options, to_ac_opf_instance, to_ac_ots_instance,
256    to_ac_ots_instance_with_options, to_ac_pf_instance, to_dc_opf_instance, to_dc_pf_instance,
257    to_lindist3flow_opf_instance, to_lindist3flow_opf_instance_with_options,
258    to_lindist3flow_pf_instance, to_lindist3flow_pf_instance_with_options, to_mc_ac_opf_instance,
259    to_mc_ac_pf_instance,
260};
261
262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
263enum Goc3DataFileKind {
264    Problem,
265    Solution,
266}
267
268#[derive(Default)]
269struct Goc3DataFiles {
270    problem: Option<SourceBuffer>,
271    solution: Option<SourceBuffer>,
272}
273
274impl Goc3DataFiles {
275    fn insert(&mut self, kind: Goc3DataFileKind, buffer: SourceBuffer) -> Result<()> {
276        let slot = match kind {
277            Goc3DataFileKind::Problem => &mut self.problem,
278            Goc3DataFileKind::Solution => &mut self.solution,
279        };
280        if let Some(existing) = slot {
281            return Err(Error::new(
282                &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
283                format!(
284                    "GO Challenge 3 source contains both `{}` and `{}` as {} data files",
285                    existing.name(),
286                    buffer.name(),
287                    match kind {
288                        Goc3DataFileKind::Problem => "problem",
289                        Goc3DataFileKind::Solution => "solution",
290                    }
291                ),
292            ));
293        }
294        *slot = Some(buffer);
295        Ok(())
296    }
297}
298
299/// The GO Challenge 3 root keys, read without building a document tree.
300#[derive(Default, serde::Deserialize)]
301struct Goc3Roots {
302    #[serde(default)]
303    network: Option<serde::de::IgnoredAny>,
304    #[serde(default)]
305    time_series_input: Option<serde::de::IgnoredAny>,
306    #[serde(default)]
307    reliability: Option<serde::de::IgnoredAny>,
308    #[serde(default)]
309    time_series_output: Option<serde::de::IgnoredAny>,
310}
311
312impl Goc3Roots {
313    fn is_problem(&self) -> bool {
314        self.network.is_some() && self.time_series_input.is_some() && self.reliability.is_some()
315    }
316
317    fn is_solution(&self) -> bool {
318        self.time_series_output.is_some()
319    }
320}
321
322/// The GO Challenge 3 root keys of a JSON document, or `None` for a well
323/// formed document whose root is not an object.
324fn goc3_roots(buffer: &SourceBuffer) -> Result<Option<Goc3Roots>> {
325    match serde_json::from_slice::<Goc3Roots>(buffer.content_bytes()) {
326        Ok(roots) => Ok(Some(roots)),
327        Err(error) if error.classify() == serde_json::error::Category::Data => Ok(None),
328        Err(error) => Err(Error::new(
329            &powerio_tx::diagnostics::codes::PARSE_GOC3_MALFORMED,
330            format!("{}: {error}", buffer.name()),
331        )),
332    }
333}
334
335fn goc3_file_kind(buffer: &SourceBuffer) -> Result<Option<Goc3DataFileKind>> {
336    let Some(roots) = goc3_roots(buffer)? else {
337        return Ok(None);
338    };
339    match (roots.is_problem(), roots.is_solution()) {
340        (true, false) => Ok(Some(Goc3DataFileKind::Problem)),
341        (false, true) => Ok(Some(Goc3DataFileKind::Solution)),
342        (false, false) => Ok(None),
343        (true, true) => Err(Error::new(
344            &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
345            format!(
346                "{} contains both the GO Challenge 3 problem and solution roots",
347                buffer.name()
348            ),
349        )),
350    }
351}
352
353fn goc3_data_files(source: &Source) -> Result<Goc3DataFiles> {
354    let mut buffers = if source.is_directory() {
355        let mut buffers = Vec::new();
356        for name in source.entry_names()? {
357            if std::path::Path::new(name.as_str())
358                .extension()
359                .and_then(|extension| extension.to_str())
360                .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
361            {
362                buffers.push(source.buffer(&name)?);
363            }
364        }
365        buffers
366    } else {
367        let mut buffers = vec![source.primary_buffer()?];
368        // `entry_names` succeeds here only for an in-memory source with named
369        // buffers. A file source never searches sibling files.
370        if let Ok(names) = source.entry_names() {
371            for name in names {
372                buffers.push(source.root_buffer(name.as_str())?);
373            }
374        }
375        buffers
376    };
377    buffers.sort_by(|left, right| left.name().cmp(right.name()));
378
379    let mut files = Goc3DataFiles::default();
380    for buffer in buffers {
381        if let Some(kind) = goc3_file_kind(&buffer)? {
382            files.insert(kind, buffer)?;
383        }
384    }
385    Ok(files)
386}
387
388fn directory_has_goc3_data(source: &Source) -> bool {
389    source.entry_names().is_ok_and(|names| {
390        names.into_iter().any(|name| {
391            std::path::Path::new(name.as_str())
392                .extension()
393                .and_then(|extension| extension.to_str())
394                .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
395                && source.buffer(&name).is_ok_and(|buffer| {
396                    goc3_roots(&buffer).is_ok_and(|roots| {
397                        roots.is_some_and(|roots| roots.is_solution() || roots.is_problem())
398                    })
399                })
400        })
401    })
402}
403
404/// Transform the `Substation` table in PowerWorld AUX text into a geographic
405/// layer without exposing the component parser's borrowed `AuxFile` type.
406///
407/// Rows without a finite number, latitude, and longitude are skipped. A valid
408/// AUX document with no usable substation coordinates returns an empty layer.
409///
410/// # Errors
411/// The AUX section syntax is malformed.
412pub fn to_geo_layer_from_aux_text(text: &str) -> Result<GeoLayer> {
413    let aux = powerio_tx::format::powerworld::aux_sections(text)
414        .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
415    Ok(powerio_tx::to_geo_layer_from_aux_substations(&aux))
416}
417
418/// A possibly partial assignment of instantaneous operating quantities over
419/// one network's fixed equipment identities.
420pub use powerio_prob::OperatingPoint;
421mod value;
422pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
423
424/// Optional configuration for [`parse_with_options`]. Every field defaults to
425/// inference, so [`ParseOptions::default`] is what [`parse`] uses.
426#[derive(Clone, Debug, Default)]
427#[non_exhaustive]
428pub struct ParseOptions {
429    /// Explicit native selections for the sincal-balanced format only.
430    #[cfg(feature = "sincal")]
431    pub sincal_balanced: Option<powerio_tnd::formats::sincal::SincalBalancedReadOptions>,
432    /// Explicit native-variant, daily-snapshot and Access-acquisition options.
433    /// Valid only with the sincal-multiconductor format selection.
434    #[cfg(feature = "sincal")]
435    pub sincal_multiconductor: Option<powerio_tnd::formats::sincal::SincalReadOptions>,
436    /// The parser selected by its stable format token rather than inferred
437    /// from the input's name and content.
438    pub format: Option<powerio_core::FormatId>,
439    /// The directory beneath which a format may refer to further files,
440    /// widening the default of the input file's own directory.
441    pub acquisition_root: Option<std::path::PathBuf>,
442}
443
444impl ParseOptions {
445    /// Select the parser by its stable format token.
446    ///
447    /// # Errors
448    /// `REQUEST.FORMAT.INVALID_ID` when the token is not a format identifier.
449    pub fn format(mut self, format: &str) -> std::result::Result<Self, powerio_core::Error> {
450        self.format = Some(powerio_core::FormatId::new(format)?);
451        Ok(self)
452    }
453
454    /// Select the parser by an already validated format identity.
455    #[must_use]
456    pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
457        self.format = Some(format);
458        self
459    }
460
461    /// Permit acquisition of files a format refers to beneath `root`.
462    #[must_use]
463    pub fn acquisition_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
464        self.acquisition_root = Some(root.into());
465        self
466    }
467}
468
469/// Parse one source into a compiled module of whichever built in family
470/// claims it. Balanced network formats produce
471/// [`PioValue::BalancedNetwork`]; network only distribution formats (OpenDSS
472/// `.dss`, PMD ENGINEERING JSON, BMOPF JSON, and distribution CIM) produce
473/// [`PioValue::MulticonductorNetwork`]. A source that defines a particular
474/// calculation produces that calculation's value. One DOE GO Challenge 3
475/// problem data file produces [`PioValue::AcScucInstance`]. One source that
476/// contains a problem data file and its matching solution data file produces
477/// [`PioValue::AcScucSolution`]; a solution data file alone is rejected because
478/// its row identities and time axis come from the problem. DeepMind OPFData
479/// JSON, which explicitly represents a solved AC OPF, produces
480/// [`PioValue::AcOpfSolution`]. The parser's findings are the module's
481/// diagnostics, and the module keeps the original input, so writing the same
482/// format again returns the original file content.
483///
484/// The input is a file or directory name, content already in memory, or a
485/// [`powerio_core::Source`] carrying named buffers or a widened acquisition
486/// root. [`parse_with_options`] selects the parser explicitly. Content in
487/// memory carries the name [`powerio_core::MEMORY_SOURCE_NAME`], which
488/// identifies no format, so a format detected from a file extension rather
489/// than from the document itself is either declared through the options or
490/// named through [`powerio_core::Source::from_memory`].
491///
492/// The family comes from the input's declared format when one was selected,
493/// and otherwise from the name and content: `.dss` and `.glm` extensions route
494/// to the distribution parser (`.glm` defaults to the PNNL GridLAB-D profile;
495/// declare `gridlabd-arras` for Arras). A `.json` document routes by its top level markers
496/// ([`format::routing::classify_json_text`]), a name with no recognized
497/// extension whose content opens a JSON document (an in-memory source has no
498/// extension) routes the same way, a `.xml` document, a name with no
499/// recognized extension, or a directory member that reads as distribution CIM
500/// ([`powerio_dist::source_looks_like_cim_dist`]) routes to the distribution parser,
501/// and every other name routes to the balanced network hub, whose own
502/// detection and refusals apply.
503///
504/// PowerIO IR is not a grid exchange format: [`parse`] refuses it and
505/// [`deserialize`] reads the current PowerIO IR document.
506///
507/// # Errors
508/// The routed family's failure, carrying its findings and the retained
509/// source.
510pub fn parse(
511    input: impl powerio_core::IntoSource,
512) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
513    parse_with_options(input, &ParseOptions::default())
514}
515
516/// Parse one input under `options`, which selects the parser explicitly or
517/// widens the directory a format may refer to further files beneath.
518/// [`parse`] is this operation with the default options.
519///
520/// # Errors
521/// The input cannot be acquired, the format cannot be selected, or the routed
522/// family fails, each carrying its own diagnostic code.
523pub fn parse_with_options(
524    input: impl powerio_core::IntoSource,
525    options: &ParseOptions,
526) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
527    let mut source = input.into_source()?;
528    if let Some(root) = &options.acquisition_root {
529        source = source.with_acquisition_root(root.clone())?;
530    }
531    if let Some(format) = &options.format {
532        source = source.with_format(format.clone());
533    }
534    #[cfg(not(feature = "sincal"))]
535    if names_sincal_source(&source) {
536        return Err(sincal_feature_disabled().with_source(source));
537    }
538    #[cfg(feature = "sincal")]
539    if options.sincal_balanced.is_some() && options.sincal_multiconductor.is_some() {
540        return Err(powerio_core::Error::new(
541            &codes::REQUEST_SINCAL_OPTIONS_PROFILE,
542            "SINCAL selections must name exactly one electrical family",
543        )
544        .with_source(source));
545    }
546    #[cfg(feature = "sincal")]
547    if let Some(sincal) = &options.sincal_balanced {
548        return powerio_tnd::formats::sincal::parse_sincal_balanced_with_options(source, sincal)
549            .map(|m| m.map_value(PioValue::from));
550    }
551    #[cfg(feature = "sincal")]
552    if let Some(sincal) = &options.sincal_multiconductor {
553        if source
554            .format()
555            .and_then(|f| format::routing::parse_distribution_format(f.as_str()))
556            != Some(format::routing::DistributionFormat::SincalMulticonductor)
557        {
558            return Err(powerio_core::Error::new(
559                &codes::REQUEST_SINCAL_OPTIONS_PROFILE,
560                "sincal_multiconductor options require explicit format sincal-multiconductor",
561            )
562            .with_source(source));
563        }
564        return powerio_tnd::formats::sincal::parse_sincal(source, sincal)
565            .map(|m| m.map_value(PioValue::from));
566    }
567    #[cfg(feature = "sincal")]
568    if names_sincal_source(&source) {
569        if source.format().is_some_and(|f| {
570            format::routing::parse_distribution_format(f.as_str())
571                == Some(format::routing::DistributionFormat::SincalMulticonductor)
572        }) {
573            return dist::parse(source).map(|m| m.map_value(PioValue::from));
574        }
575        return powerio_tnd::formats::sincal::parse_balanced(source)
576            .map(|m| m.map_value(PioValue::from));
577    }
578    match routed_family(&source)? {
579        RoutedFamily::Goc3 => parse_goc3(source),
580        RoutedFamily::OpfData => powerio_prob::__internal::__decode_opfdata_solution(source)
581            .map(|module| module.map_value(PioValue::from)),
582        RoutedFamily::Distribution(detected) => {
583            let source = match (source.format(), detected) {
584                (None, Some(format)) => {
585                    source.with_format(powerio_core::FormatId::new(format.name())?)
586                }
587                _ => source,
588            };
589            // A PMD multinetwork document holds one network per time step.
590            let pmd = source.format().is_some_and(|f| {
591                format::routing::parse_distribution_format(f.as_str())
592                    == Some(format::routing::DistributionFormat::PmdJson)
593            });
594            if pmd && powerio_dist::pmd::is_multinetwork(&source) {
595                return powerio_dist::pmd::parse_multinetwork(source)
596                    .map(|module| module.map_value(PioValue::from));
597            }
598            dist::parse(source).map(|module| module.map_value(PioValue::from))
599        }
600        RoutedFamily::PypsaDirectory => parse_pypsa(source),
601        #[cfg(feature = "gridfm")]
602        RoutedFamily::Gridfm => parse_gridfm(source),
603        RoutedFamily::Egret => parse_egret(source),
604        RoutedFamily::Geo => parse_geo_layer(source),
605        RoutedFamily::Contingency(kind) => parse_contingency_file(source, kind),
606        RoutedFamily::Balanced(json_class) => format::parse_with_json_class(source, json_class)
607            .map(|module| module.map_value(PioValue::from)),
608    }
609}
610
611/// Parse the official GO Challenge 3 problem file, or a problem and its
612/// matching solution file supplied by one directory or one memory source.
613/// File roles come from the required top level JSON fields, not filenames.
614/// A solution file alone is incomplete because it contains neither the
615/// component definitions nor the time axis.
616fn parse_goc3(
617    source: powerio_core::Source,
618) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
619    let source = source.with_format(powerio_core::FormatId::new("goc3-json")?);
620    let files = match goc3_data_files(&source) {
621        Ok(files) => files,
622        Err(error) => return Err(error.with_source(source)),
623    };
624    let Some(problem) = files.problem else {
625        let message = if files.solution.is_some() {
626            "a GO Challenge 3 solution file requires the matching problem file in the same source"
627        } else {
628            "the source contains neither a GO Challenge 3 problem file nor a solution file"
629        };
630        return Err(Error::new(
631            &powerio_tx::diagnostics::codes::READ_GOC3_PROBLEM_REQUIRED,
632            message,
633        )
634        .with_source(source));
635    };
636
637    let (instance, diagnostics) =
638        match powerio_prob::__internal::__parse_goc3_problem_buffer(&problem) {
639            Ok(parsed) => parsed,
640            Err(error) => return Err(error.with_source(source)),
641        };
642    let value = match files.solution {
643        Some(solution) => {
644            let solution = match powerio_prob::__internal::__parse_goc3_output_buffer(
645                std::sync::Arc::new(instance),
646                &solution,
647            ) {
648                Ok(solution) => solution,
649                Err(error) => return Err(error.with_source(source)),
650            };
651            PioValue::from(solution)
652        }
653        None => PioValue::from(instance),
654    };
655    powerio_core::PioModule::parsed(value, source, diagnostics)
656}
657
658/// Read one standalone geographic document into [`PioValue::GeoLayer`]. A
659/// PowerWorld `.pwd` display lifts into a diagram space layer with substation
660/// targets; every other supported document is already a layer. The reader's
661/// notes on records it could not use become the module's diagnostics.
662fn parse_geo_layer(
663    source: powerio_core::Source,
664) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
665    let name = source.name().to_owned();
666    let declared = source.format().map(|format| format.as_str().to_owned());
667    let is_display = declared.as_deref().is_some_and(is_pwd_display_token)
668        || std::path::Path::new(&name)
669            .extension()
670            .and_then(|extension| extension.to_str())
671            .is_some_and(|extension| extension.eq_ignore_ascii_case("pwd"));
672
673    let buffer = match source.primary_buffer() {
674        Ok(buffer) => buffer,
675        Err(error) => return Err(error.with_source(source)),
676    };
677    let (layer, diagnostics) = if is_display {
678        match powerio_tx::format::powerworld::__parse_pwd_layer(buffer.content_bytes()) {
679            Ok(parsed) => (parsed.layer, parsed.diagnostics),
680            Err(error) => {
681                return Err(Error::new(error.code(), error.to_string())
682                    .with_cause(error)
683                    .with_source(source));
684            }
685        }
686    } else {
687        let text = match std::str::from_utf8(buffer.content_bytes()) {
688            Ok(text) => text,
689            Err(cause) => {
690                return Err(Error::new(
691                    &powerio_tx::diagnostics::codes::READ_GEO_NOT_TEXT,
692                    format!("a geographic layer document is not valid UTF-8: {cause}"),
693                )
694                .with_source(source));
695            }
696        };
697        match powerio_tx::geo::GeoLayer::parse(
698            text,
699            std::path::Path::new(&name)
700                .file_name()
701                .and_then(|name| name.to_str()),
702        ) {
703            Ok(parsed) => (parsed.layer, parsed.diagnostics),
704            Err(error) => {
705                return Err(Error::new(error.code(), error.to_string())
706                    .with_cause(error)
707                    .with_source(source));
708            }
709        }
710    };
711    let source = match declared {
712        Some(_) => source,
713        None => source.with_format(powerio_core::FormatId::new(if is_display {
714            "powerworld-pwd"
715        } else {
716            "geo-json"
717        })?),
718    };
719    powerio_core::PioModule::parsed(PioValue::from(layer), source, diagnostics)
720}
721
722/// Read one PSS/E contingency analysis file into its own value. The reader
723/// keeps a statement outside its grammar as the original line and reports it;
724/// those notes become the module's diagnostics, so nothing the file states is
725/// lost between the text and the value.
726fn parse_contingency_file(
727    source: powerio_core::Source,
728    kind: ContingencyFile,
729) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
730    let buffer = match source.primary_buffer() {
731        Ok(buffer) => buffer,
732        Err(error) => return Err(error.with_source(source)),
733    };
734    let text = match std::str::from_utf8(buffer.content_bytes()) {
735        Ok(text) => text,
736        Err(cause) => {
737            return Err(Error::new(
738                kind.not_text_code(),
739                format!("{} is not valid UTF-8: {cause}", kind.description()),
740            )
741            .with_source(source));
742        }
743    };
744    let parsed = match kind {
745        ContingencyFile::Con => powerio_tx::ContingencySet::parse(text)
746            .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
747        ContingencyFile::Sub => powerio_tx::SubsystemSet::parse(text)
748            .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
749        ContingencyFile::Mon => powerio_tx::MonitoredSet::parse(text)
750            .map(|parsed| (PioValue::from(parsed.set), parsed.diagnostics)),
751    };
752    let (value, diagnostics) = match parsed {
753        Ok(parsed) => parsed,
754        Err(error) => {
755            return Err(Error::new(error.code(), error.to_string())
756                .with_cause(error)
757                .with_source(source));
758        }
759    };
760    let source = match source.format() {
761        Some(_) => source,
762        None => source.with_format(powerio_core::FormatId::new(kind.token())?),
763    };
764    powerio_core::PioModule::parsed(value, source, diagnostics)
765}
766
767/// PyPSA CSV dispatch: one snapshot with no series siblings is the scalar
768/// profile through the balanced hub; a declared axis routes to the sequence
769/// parser, producing a network series or, when only operating quantities
770/// vary, an operating point series over one shared network.
771fn parse_pypsa(
772    source: powerio_core::Source,
773) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
774    if !source.is_directory() {
775        // A file claiming the PyPSA token gets the hub's own refusal wording.
776        return format::parse(source).map(|module| module.map_value(PioValue::from));
777    }
778    // Directory routing identified the source before the typed reader runs.
779    // Record that decision on an undeclared source so same format emission
780    // can distinguish a PyPSA directory from the GridFM directory family.
781    let source = match source.format() {
782        Some(_) => source,
783        None => source.with_format(powerio_core::FormatId::new("pypsa-csv")?),
784    };
785    let axis = match format::__pypsa_axis(&source) {
786        Ok(axis) => axis,
787        Err(error) => {
788            let core = powerio_core::Error::new(error.code(), error.to_string());
789            return Err(core.with_source(source));
790        }
791    };
792    match axis {
793        format::PypsaAxis::SingleSnapshot => {
794            format::parse(source).map(|module| module.map_value(PioValue::from))
795        }
796        format::PypsaAxis::Series => {
797            match powerio_prob::__internal::__decode_pypsa_sequence(&source) {
798                Ok((sequence, diagnostics)) => {
799                    let value = match sequence {
800                        powerio_prob::__internal::PypsaSequence::Networks(series) => {
801                            PioValue::from(series)
802                        }
803                        powerio_prob::__internal::PypsaSequence::OperatingPoints(points) => {
804                            PioValue::from(points)
805                        }
806                    };
807                    powerio_core::PioModule::parsed(value, source, diagnostics)
808                }
809                Err(error) => Err(error.with_source(source)),
810            }
811        }
812    }
813}
814
815/// gridfm dispatch: every scenario of the Parquet dataset as one scenario
816/// set over shared element identities.
817#[cfg(feature = "gridfm")]
818fn parse_gridfm(
819    source: powerio_core::Source,
820) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
821    if !source.is_directory() {
822        // A file claiming the gridfm token gets the hub's own refusal wording.
823        return format::parse(source).map(|module| module.map_value(PioValue::from));
824    }
825    let source = match source.format() {
826        Some(_) => source,
827        None => source.with_format(powerio_core::FormatId::new("gridfm")?),
828    };
829    match __gridfm::parse_gridfm_source(&source) {
830        Ok((set, diagnostics)) => {
831            powerio_core::PioModule::parsed(PioValue::from(set), source, diagnostics)
832        }
833        Err(error) => Err(error.with_source(source)),
834    }
835}
836
837/// Egret dispatch: a document declaring `system.time_keys` routes to the
838/// sequence parser and produces a balanced network time series; a scalar
839/// document routes through the balanced hub.
840fn parse_egret(
841    source: powerio_core::Source,
842) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
843    let declares_series = {
844        let buffer = source.primary_buffer()?;
845        std::str::from_utf8(buffer.content_bytes()).is_ok_and(format::__egret_declares_time_series)
846    };
847    if !declares_series {
848        return format::parse(source).map(|module| module.map_value(PioValue::from));
849    }
850    let parsed = {
851        let buffer = source.primary_buffer()?;
852        let stem = std::path::Path::new(source.name())
853            .file_stem()
854            .and_then(|stem| stem.to_str())
855            .map(str::to_owned);
856        match std::str::from_utf8(buffer.content_bytes()) {
857            Ok(text) => format::__parse_egret_time_series(text, stem.as_deref())
858                .map_err(|error| powerio_core::Error::new(error.code(), error.to_string())),
859            Err(error) => {
860                let cause = powerio_tx::Error::FormatRead {
861                    format: "case text",
862                    message: format!("not valid UTF-8: {error}"),
863                };
864                Err(powerio_core::Error::new(cause.code(), cause.to_string()))
865            }
866        }
867    };
868    match parsed {
869        Ok(series) => powerio_core::PioModule::parsed(PioValue::from(series), source, Vec::new()),
870        Err(error) => Err(error.with_source(source)),
871    }
872}
873
874/// The family a source routes to. The balanced hub is the default: it owns
875/// the guidance for unknown names and refused shapes. `Balanced` carries the
876/// JSON classification when routing here was itself the result of one (so
877/// the balanced hub does not classify the same text a second time); `None`
878/// when the source routed here by extension, by a declared token, or by a
879/// directory shape, none of which run a JSON classification at all.
880enum RoutedFamily {
881    Balanced(Option<format::routing::JsonClass>),
882    Distribution(Option<format::routing::DistributionFormat>),
883    Goc3,
884    OpfData,
885    PypsaDirectory,
886    Egret,
887    /// A standalone geographic document: the canonical `.geo.json`, GeoJSON,
888    /// aliased CSV or JSON records, headerless buscoords CSV, or a PowerWorld
889    /// `.pwd` display lifted into a diagram space layer.
890    Geo,
891    /// One of the three text files a PSS/E contingency analysis reads.
892    Contingency(ContingencyFile),
893    #[cfg(feature = "gridfm")]
894    Gridfm,
895}
896
897/// Whether `source` names a SINCAL project: a declared format that resolves
898/// to one of the SINCAL tokens, or an undeclared `.sinx` archive. Content
899/// never selects the family, so SQLite bytes under another name route as
900/// before.
901fn names_sincal_source(source: &powerio_core::Source) -> bool {
902    match source.format() {
903        Some(format) => write::is_sincal_format(format.as_str()),
904        None => std::path::Path::new(source.name())
905            .extension()
906            .is_some_and(|extension| extension.eq_ignore_ascii_case("sinx")),
907    }
908}
909
910/// The refusal a SINCAL source or target meets in a build without the
911/// `sincal` feature. One code for parse and emit, shared with `powerio-tx`.
912#[cfg(not(feature = "sincal"))]
913pub(crate) fn sincal_feature_disabled() -> powerio_core::Error {
914    powerio_core::Error::new(
915        &powerio_tx::diagnostics::codes::REQUEST_SINCAL_FEATURE_DISABLED,
916        "this build of powerio compiled without the `sincal` feature, so it cannot read or write SINCAL projects; enable the `sincal` feature (on by default) to include the SQLite backed readers",
917    )
918}
919
920fn routed_family(
921    source: &powerio_core::Source,
922) -> std::result::Result<RoutedFamily, powerio_core::Error> {
923    if let Some(declared) = source.format() {
924        return Ok(family_of_token(declared.as_str()));
925    }
926    if source.is_directory() {
927        // GOC3 pairs are identified by their official JSON roots rather than
928        // filenames. The format parser performs the exact cardinality and
929        // schema checks after routing.
930        if directory_has_goc3_data(source) {
931            return Ok(RoutedFamily::Goc3);
932        }
933        // PyPSA is a CSV folder containing network.csv. GridFM is a Parquet
934        // dataset with bus_data.parquet at one of its documented locations.
935        // Anything else falls to the balanced hub's refusal.
936        let marker = powerio_core::ArtifactPath::new("network.csv")
937            .expect("static name is a valid artifact path");
938        if source.buffer(&marker).is_ok() {
939            return Ok(RoutedFamily::PypsaDirectory);
940        }
941        #[cfg(feature = "gridfm")]
942        if let Ok(entries) = source.entry_names()
943            && entries.iter().any(|entry| {
944                entry.as_str().ends_with("bus_data.parquet")
945                    && matches!(entry.as_str().matches('/').count(), 0..=2)
946            })
947        {
948            return Ok(RoutedFamily::Gridfm);
949        }
950        // Distribution CIM documents carry per phase classes and no CGMES
951        // model header; a CGMES profile directory has neither.
952        if powerio_dist::source_looks_like_cim_dist(source) {
953            return Ok(RoutedFamily::Distribution(Some(
954                format::routing::DistributionFormat::CimDist,
955            )));
956        }
957        return Ok(RoutedFamily::Balanced(None));
958    }
959    let extension = std::path::Path::new(source.name())
960        .extension()
961        .and_then(|extension| extension.to_str())
962        .unwrap_or_default()
963        .to_ascii_lowercase();
964    if has_geo_layer_extension(source.name()) {
965        return Ok(RoutedFamily::Geo);
966    }
967    match extension.as_str() {
968        "glm" => Ok(RoutedFamily::Distribution(Some(
969            format::routing::DistributionFormat::Gridlabd,
970        ))),
971        "dss" => Ok(RoutedFamily::Distribution(Some(
972            format::routing::DistributionFormat::Dss,
973        ))),
974        "json" => json_family(source),
975        // CIM RDF/XML is CGMES unless the document reads as distribution
976        // CIM; XIIDM never declares the RDF and CIM namespaces.
977        "xml" if powerio_dist::source_looks_like_cim_dist(source) => Ok(
978            RoutedFamily::Distribution(Some(format::routing::DistributionFormat::CimDist)),
979        ),
980        // Extensions with dedicated non-JSON readers keep them; anything
981        // else (a nameless in-memory source above all) can still carry a
982        // JSON document, so content that opens one routes by classification,
983        // mirroring the balanced hub's own sniff.
984        "pwd" | "geojson" => Ok(RoutedFamily::Geo),
985        "con" => Ok(RoutedFamily::Contingency(ContingencyFile::Con)),
986        "sub" => Ok(RoutedFamily::Contingency(ContingencyFile::Sub)),
987        "mon" => Ok(RoutedFamily::Contingency(ContingencyFile::Mon)),
988        "m" | "raw" | "aux" | "epc" | "pwb" | "uct" => Ok(RoutedFamily::Balanced(None)),
989        _ => {
990            let jsonish = source.primary_buffer().is_ok_and(|buffer| {
991                std::str::from_utf8(buffer.content_bytes()).is_ok_and(|text| {
992                    // Strip a UTF-8 BOM the way the JSON classifier does, so
993                    // a BOM-prefixed nameless document routes the same as
994                    // the identical content saved with a .json name.
995                    text.trim_start_matches('\u{feff}')
996                        .trim_start()
997                        .starts_with(['{', '['])
998                })
999            });
1000            if jsonish {
1001                json_family(source)
1002            } else if powerio_dist::source_looks_like_cim_dist(source) {
1003                Ok(RoutedFamily::Distribution(Some(
1004                    format::routing::DistributionFormat::CimDist,
1005                )))
1006            } else {
1007                Ok(RoutedFamily::Balanced(None))
1008            }
1009        }
1010    }
1011}
1012
1013/// Whether `name` carries the compound `geo.json` extension: the whole name,
1014/// or the name after a separator, so `layer.geo.json`, `layer_geo.json`, and
1015/// `layer-geo.json` all state a layer. A stem that merely ends in the same
1016/// letters (`apogeo.json`) does not, and keeps its JSON classification, which
1017/// matters because JSON content classification has no layer verdict and would
1018/// refuse the file as an unrecognized case.
1019fn has_geo_layer_extension(name: &str) -> bool {
1020    let name = name.to_ascii_lowercase();
1021    let extension = powerio_tx::geo::GEO_LAYER_EXTENSION;
1022    name == extension
1023        || name
1024            .strip_suffix(extension)
1025            .is_some_and(|stem| stem.ends_with(['.', '_', '-', '/', '\\']))
1026}
1027
1028/// Whether `token` names the standalone geographic layer document.
1029pub(crate) fn is_geo_layer_token(token: &str) -> bool {
1030    matches!(
1031        token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
1032        "geojson" | "geo" | "geolayer"
1033    )
1034}
1035
1036/// Whether `token` names a PowerWorld display file, which reads as a diagram
1037/// space layer.
1038pub(crate) fn is_pwd_display_token(token: &str) -> bool {
1039    matches!(
1040        token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
1041        "pwd" | "powerworldpwd" | "powerworlddisplay"
1042    )
1043}
1044
1045/// Whether `token` names a document that reads into [`PioValue::GeoLayer`].
1046fn is_geo_token(token: &str) -> bool {
1047    is_geo_layer_token(token) || is_pwd_display_token(token)
1048}
1049
1050/// Which of the three PSS/E contingency analysis files a source holds. Each
1051/// is its own format token, its own value type, and its own writer; the three
1052/// never cross.
1053#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1054pub(crate) enum ContingencyFile {
1055    /// The contingency description file, `.con`.
1056    Con,
1057    /// The subsystem description file, `.sub`.
1058    Sub,
1059    /// The monitored element file, `.mon`.
1060    Mon,
1061}
1062
1063impl ContingencyFile {
1064    /// The canonical format token.
1065    pub(crate) const fn token(self) -> &'static str {
1066        match self {
1067            Self::Con => "psse-con",
1068            Self::Sub => "psse-sub",
1069            Self::Mon => "psse-mon",
1070        }
1071    }
1072
1073    /// The conventional filename suffix, without a leading dot.
1074    pub(crate) const fn extension(self) -> &'static str {
1075        match self {
1076            Self::Con => "con",
1077            Self::Sub => "sub",
1078            Self::Mon => "mon",
1079        }
1080    }
1081
1082    /// The name of the one artifact an emission writes. A path destination
1083    /// uses the caller's own path; a memory destination uses its root name.
1084    /// `con` alone is a reserved device name on Windows, so every name here
1085    /// carries a stem.
1086    pub(crate) const fn artifact_name(self) -> &'static str {
1087        match self {
1088            Self::Con => "contingency.con",
1089            Self::Sub => "subsystem.sub",
1090            Self::Mon => "monitored.mon",
1091        }
1092    }
1093
1094    /// The structural type name the file reads into.
1095    pub(crate) const fn type_name(self) -> &'static str {
1096        match self {
1097            Self::Con => "powerio.ContingencySet",
1098            Self::Sub => "powerio.SubsystemSet",
1099            Self::Mon => "powerio.MonitoredSet",
1100        }
1101    }
1102
1103    /// The code for input that is not UTF-8 text.
1104    const fn not_text_code(self) -> &'static powerio_core::DiagnosticInfo {
1105        use powerio_tx::diagnostics::codes;
1106        match self {
1107            Self::Con => &codes::READ_CON_NOT_TEXT,
1108            Self::Sub => &codes::READ_SUB_NOT_TEXT,
1109            Self::Mon => &codes::READ_MON_NOT_TEXT,
1110        }
1111    }
1112
1113    /// How the refusal names the file, in prose.
1114    const fn description(self) -> &'static str {
1115        match self {
1116            Self::Con => "a PSS/E contingency description file",
1117            Self::Sub => "a PSS/E subsystem description file",
1118            Self::Mon => "a PSS/E monitored element file",
1119        }
1120    }
1121}
1122
1123/// The PSS/E contingency analysis file a format token names, or `None` for
1124/// every other token. Case, hyphens, and underscores do not distinguish
1125/// spellings, so `psse-con`, `psse_con`, `PSSECon`, and `con` are one name.
1126pub(crate) fn contingency_file_of_token(token: &str) -> Option<ContingencyFile> {
1127    match token.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
1128        "con" | "pssecon" | "contingency" => Some(ContingencyFile::Con),
1129        "sub" | "pssesub" | "subsystem" => Some(ContingencyFile::Sub),
1130        "mon" | "pssemon" | "monitored" => Some(ContingencyFile::Mon),
1131        _ => None,
1132    }
1133}
1134
1135/// The family a JSON document's content markers select.
1136fn json_family(
1137    source: &powerio_core::Source,
1138) -> std::result::Result<RoutedFamily, powerio_core::Error> {
1139    use format::routing::{Detection, JsonClass, SourceFormat, TransmissionFormat};
1140
1141    let buffer = source.primary_buffer()?;
1142    // Family routing needs decoded text; a non-UTF-8 `.json` fails in
1143    // the balanced hub with its own wording. Classification never ran, so
1144    // the balanced hub gets no hint and classifies it itself.
1145    let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
1146        return Ok(RoutedFamily::Balanced(None));
1147    };
1148    let class = format::routing::classify_json_text(text);
1149    match class {
1150        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1151            TransmissionFormat::Goc3Json,
1152        ))) => Ok(RoutedFamily::Goc3),
1153        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1154            TransmissionFormat::DeepMindOpfDataJson,
1155        ))) => Ok(RoutedFamily::OpfData),
1156        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
1157            TransmissionFormat::EgretJson,
1158        ))) => Ok(RoutedFamily::Egret),
1159        JsonClass::Case(Detection::Known(SourceFormat::Distribution(format))) => {
1160            Ok(RoutedFamily::Distribution(Some(format)))
1161        }
1162        JsonClass::Module => Err(powerio_core::Error::new(
1163            &codes::REQUEST_PARSE_POWERIO_IR,
1164            "PowerIO IR is not a grid exchange format; call deserialize(source)",
1165        )),
1166        // The balanced hub owns the refusal wording for unrecognized or
1167        // ambiguous documents. Pass the classification through so it does not
1168        // inspect the same bytes twice.
1169        JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
1170            Ok(RoutedFamily::Balanced(Some(class)))
1171        }
1172    }
1173}
1174
1175/// The family a declared format token selects. Unknown tokens fall to the
1176/// balanced hub, which owns the refusal wording and the accepted name list.
1177fn family_of_token(token: &str) -> RoutedFamily {
1178    use format::TargetFormat;
1179
1180    if is_geo_token(token) {
1181        return RoutedFamily::Geo;
1182    }
1183    if let Some(kind) = contingency_file_of_token(token) {
1184        return RoutedFamily::Contingency(kind);
1185    }
1186
1187    if let Some(format) = format::routing::parse_distribution_format(token) {
1188        return RoutedFamily::Distribution(Some(format));
1189    }
1190    if format::is_pypsa_csv_name(token) {
1191        return RoutedFamily::PypsaDirectory;
1192    }
1193    #[cfg(feature = "gridfm")]
1194    if token.eq_ignore_ascii_case("gridfm") {
1195        return RoutedFamily::Gridfm;
1196    }
1197    match format::parse_target_format(token) {
1198        Some(TargetFormat::Goc3Json) => RoutedFamily::Goc3,
1199        Some(TargetFormat::DeepMindOpfDataJson) => RoutedFamily::OpfData,
1200        Some(TargetFormat::EgretJson) => RoutedFamily::Egret,
1201        _ => RoutedFamily::Balanced(None),
1202    }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    fn memory(name: &str, text: &str) -> powerio_core::Source {
1210        powerio_core::Source::from_memory(name, text.as_bytes().to_vec()).expect("memory source")
1211    }
1212
1213    fn parse(
1214        source: powerio_core::Source,
1215    ) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
1216        super::parse(source)
1217    }
1218
1219    fn options(format: Option<&str>) -> ParseOptions {
1220        match format {
1221            Some(format) => ParseOptions::default().format(format).unwrap(),
1222            None => ParseOptions::default(),
1223        }
1224    }
1225
1226    fn assert_value_type(module: &powerio_core::PioModule<PioValue>, expected: &str) {
1227        assert_eq!(module.value().type_name(), expected);
1228    }
1229
1230    #[test]
1231    fn a_matpower_source_parses_to_a_balanced_network() {
1232        let case = "function mpc = case\n\
1233                    mpc.version = '2';\n\
1234                    mpc.baseMVA = 100;\n\
1235                    mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1236                    mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1237                    mpc.branch = [];\n";
1238        let module = parse(
1239            memory("case.m", case).with_format(powerio_core::FormatId::new("matpower").unwrap()),
1240        )
1241        .expect("matpower parses");
1242        assert_value_type(&module, "powerio.BalancedNetwork");
1243    }
1244
1245    #[test]
1246    fn memory_parse_retains_its_name_and_optional_format() {
1247        let case = "function mpc = inline\n\
1248                    mpc.version = '2';\n\
1249                    mpc.baseMVA = 100;\n\
1250                    mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
1251                    mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
1252                    mpc.branch = [];\n";
1253
1254        let detected = super::parse(memory("inline-case.m", case)).expect("name detects MATPOWER");
1255        assert_eq!(detected.source().unwrap().name(), "inline-case.m");
1256        assert_eq!(
1257            detected.source().unwrap().format().map(FormatId::as_str),
1258            Some("matpower")
1259        );
1260
1261        let declared = super::parse_with_options(
1262            memory("consumer-input", case),
1263            &ParseOptions::default().format("matpower").unwrap(),
1264        )
1265        .expect("declared MATPOWER");
1266        let source = declared.source().expect("source retained");
1267        assert_eq!(source.name(), "consumer-input");
1268        assert_eq!(source.format().map(FormatId::as_str), Some("matpower"));
1269    }
1270
1271    #[test]
1272    fn universal_parse_reads_declared_iso_8859_1_xiidm_and_retains_exact_bytes() {
1273        let text = r#"<?xml version="1.0" encoding="ISO-8859-1"?>
1274<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_17" id="case" caseDate="2026-01-01T00:00:00Z" forecastDistance="0" sourceFormat="Réseau PowSybl" minimumValidationLevel="STEADY_STATE_HYPOTHESIS">
1275  <iidm:voltageLevel id="VL" nominalV="225" topologyKind="BUS_BREAKER">
1276    <iidm:busBreakerTopology><iidm:bus id="B" v="225" angle="0"/></iidm:busBreakerTopology>
1277    <iidm:generator id="G" energySource="OTHER" minP="0" maxP="100" voltageRegulatorOn="true" targetP="50" targetV="225" bus="B" connectableBus="B"><iidm:minMaxReactiveLimits minQ="-20" maxQ="20"/></iidm:generator>
1278  </iidm:voltageLevel>
1279</iidm:network>"#;
1280        let bytes: Vec<u8> = text
1281            .chars()
1282            .map(|value| u8::try_from(u32::from(value)).expect("fixture is ISO-8859-1"))
1283            .collect();
1284        assert!(std::str::from_utf8(&bytes).is_err());
1285
1286        for (name, format) in [
1287            ("case.xiidm", None),
1288            ("case.xml", None),
1289            ("memory", Some("xiidm")),
1290        ] {
1291            let source = Source::from_memory(name, bytes.clone()).unwrap();
1292            let module = super::parse_with_options(source, &options(format)).unwrap();
1293            let PioValue::BalancedNetwork(network) = &module.value() else {
1294                panic!(
1295                    "expected BalancedNetwork, got {}",
1296                    module.value().type_name()
1297                );
1298            };
1299            assert_eq!(
1300                network.case_metadata().source_model_format.as_deref(),
1301                Some("Réseau PowSybl")
1302            );
1303            let retained = module.source().unwrap();
1304            assert_eq!(retained.format().map(FormatId::as_str), Some("xiidm"));
1305            assert_eq!(retained.primary_buffer().unwrap().bytes(), bytes);
1306
1307            let emitted =
1308                emit(&module, "xiidm", Destination::memory("copy.xiidm").unwrap()).unwrap();
1309            assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1310            let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1311                panic!("memory destination returned a path output");
1312            };
1313            assert_eq!(artifacts.len(), 1);
1314            assert_eq!(artifacts[0].bytes(), bytes);
1315        }
1316    }
1317
1318    #[test]
1319    fn a_dss_source_parses_to_a_multiconductor_network() {
1320        let module = parse(memory(
1321            "feeder.dss",
1322            "New Circuit.c basekv=12.47 bus1=src\n",
1323        ))
1324        .expect("dss parses");
1325        let PioValue::MulticonductorNetwork(network) = &module.value() else {
1326            panic!(
1327                "expected multiconductor network, got {}",
1328                module.value().type_name()
1329            );
1330        };
1331        assert_eq!(network.name().as_deref(), Some("c"));
1332    }
1333
1334    #[test]
1335    fn distribution_cim_routes_by_content_and_echoes_its_bytes() {
1336        let fixture = concat!(
1337            env!("CARGO_MANIFEST_DIR"),
1338            "/../tests/data/dist/cim/micro_feeder.xml"
1339        );
1340        let bytes = std::fs::read(fixture).unwrap();
1341        // By the `.xml` extension, with no name at all, and from a
1342        // directory holding the document.
1343        let dir = tempfile::tempdir().unwrap();
1344        std::fs::write(dir.path().join("feeder.xml"), &bytes).unwrap();
1345        for source in [
1346            Source::from_memory("feeder.xml", bytes.clone()).unwrap(),
1347            Source::from_memory("<memory>", bytes.clone()).unwrap(),
1348            Source::open(dir.path()).unwrap(),
1349        ] {
1350            let module = parse(source).expect("distribution CIM parses");
1351            assert_value_type(&module, "powerio.MulticonductorNetwork");
1352            assert_eq!(
1353                module
1354                    .source()
1355                    .and_then(Source::format)
1356                    .map(FormatId::as_str),
1357                Some("cim-dist")
1358            );
1359        }
1360        let module = parse(Source::from_memory("feeder.xml", bytes.clone()).unwrap()).unwrap();
1361        let emitted = emit(
1362            &module,
1363            "cim-dist",
1364            Destination::memory("copy.xml").unwrap(),
1365        )
1366        .unwrap();
1367        assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1368        let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1369            panic!("memory destination returned a path output");
1370        };
1371        assert_eq!(artifacts[0].bytes(), bytes);
1372
1373        // The network records where it came from, and the IR carries it.
1374        let PioValue::MulticonductorNetwork(network) = module.value() else {
1375            panic!("distribution CIM parses to a multiconductor network");
1376        };
1377        assert_eq!(
1378            *network.source_format(),
1379            Some(powerio_dist::DistSourceFormat::CimDist)
1380        );
1381        let stored = serialize(&module, Destination::memory("feeder.pio.json").unwrap()).unwrap();
1382        let EmittedOutput::Memory { artifacts } = stored.into_output() else {
1383            panic!("memory destination returned a path output");
1384        };
1385        let back = deserialize(
1386            Source::from_memory("feeder.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1387        )
1388        .unwrap();
1389        let PioValue::MulticonductorNetwork(network) = back.value() else {
1390            panic!("the IR keeps the multiconductor network");
1391        };
1392        assert_eq!(
1393            *network.source_format(),
1394            Some(powerio_dist::DistSourceFormat::CimDist)
1395        );
1396    }
1397
1398    #[test]
1399    fn a_declared_distribution_format_routes_without_an_extension() {
1400        let module = parse(
1401            memory("<memory>", "New Circuit.c basekv=12.47 bus1=src\n")
1402                .with_format(powerio_core::FormatId::new("dss").unwrap()),
1403        )
1404        .expect("declared dss parses");
1405        assert_value_type(&module, "powerio.MulticonductorNetwork");
1406    }
1407
1408    #[test]
1409    fn json_routes_by_top_level_markers() {
1410        // A PMD document carries `data_model`, which no balanced format does.
1411        let module = parse(memory(
1412            "feeder.json",
1413            r#"{"data_model": "ENGINEERING", "bus": {}}"#,
1414        ))
1415        .expect("pmd parses");
1416        assert_value_type(&module, "powerio.MulticonductorNetwork");
1417    }
1418
1419    #[test]
1420    fn a_bare_network_object_is_not_powerio_ir_or_a_case_format() {
1421        let error = parse(memory(
1422            "net.json",
1423            r#"{"name":"network","base_mva":100.0,"buses":[],"branches":[]}"#,
1424        ))
1425        .expect_err("an unmarked network object must not parse");
1426        assert!(error.to_string().contains("cannot infer JSON format"));
1427    }
1428
1429    #[test]
1430    fn the_error_path_retains_the_source() {
1431        let error = parse(memory("case.m", "not matpower at all")).expect_err("malformed");
1432        assert!(error.retained_source().is_some());
1433    }
1434
1435    fn fixture(path: &str) -> String {
1436        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1437        std::fs::read_to_string(root.join(path)).unwrap()
1438    }
1439
1440    #[test]
1441    fn goc3_parses_to_an_scuc_instance() {
1442        // A calculation defining source produces its calculation's value,
1443        // never the bare network its data could also build.
1444        let text = fixture("../powerio-prob/tests/data/goc3_small.json");
1445        let module = parse(memory("goc3_small.json", &text)).expect("goc3 parses");
1446        assert_value_type(&module, "powerio.AcScucInstance");
1447        assert!(module.source().is_some());
1448        let PioValue::AcScucInstance(instance) = &module.value() else {
1449            unreachable!();
1450        };
1451        assert_eq!(instance.network().buses().len(), 2);
1452    }
1453
1454    #[test]
1455    fn goc3_problem_and_solution_parse_with_the_one_public_operation() {
1456        let problem = fixture("../tests/data/goc3/goc3_small.json");
1457        let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1458        let source = powerio_core::Source::from_memory("problem.json", problem.into_bytes())
1459            .unwrap()
1460            .with_named_buffer("solution.json", solution.into_bytes())
1461            .unwrap();
1462        let module = parse(source).expect("problem and solution parse together");
1463        let PioValue::AcScucSolution(solution) = &module.value() else {
1464            panic!(
1465                "expected AC SCUC solution, got {}",
1466                module.value().type_name()
1467            );
1468        };
1469        assert_eq!(solution.instance().network().buses().len(), 2);
1470        assert_eq!(
1471            solution.network_outputs().shunt_step,
1472            vec![vec![1], vec![2]]
1473        );
1474        assert_eq!(module.sources().len(), 2);
1475
1476        let emitted = emit(
1477            &module,
1478            "goc3-json",
1479            Destination::memory("solution.json").unwrap(),
1480        )
1481        .expect("solution emits as official GOC3 output");
1482        let EmittedOutput::Memory { artifacts } = emitted.output() else {
1483            unreachable!();
1484        };
1485        assert_eq!(artifacts.len(), 1);
1486        let document: serde_json::Value = serde_json::from_slice(artifacts[0].bytes()).unwrap();
1487        assert!(document.get("time_series_output").is_some());
1488        assert!(document.get("network").is_none());
1489    }
1490
1491    #[test]
1492    fn goc3_solution_alone_names_the_missing_problem() {
1493        let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1494        let error = parse(memory("solution.json", &solution))
1495            .expect_err("a solution without its problem is incomplete");
1496        assert!(error.to_string().contains("matching problem file"));
1497        assert!(error.retained_source().is_some());
1498    }
1499
1500    #[test]
1501    fn opfdata_parses_to_an_ac_opf_solution() {
1502        let text = fixture("../tests/data/opfdataset/example_0.json");
1503        let module = parse(memory("example_0.json", &text)).expect("opfdata parses");
1504        let PioValue::AcOpfSolution(solution) = &module.value() else {
1505            panic!(
1506                "expected AC OPF solution, got {}",
1507                module.value().type_name()
1508            );
1509        };
1510        assert_eq!(
1511            module
1512                .sources()
1513                .first()
1514                .and_then(|source| source.format())
1515                .map(powerio_core::FormatId::as_str),
1516            Some("opfdata-json")
1517        );
1518        assert_eq!(
1519            *solution.termination(),
1520            powerio_prob::Termination::NotReported
1521        );
1522        assert!((solution.objective() - 2_265.953_939_003_096).abs() < 1e-9);
1523
1524        let instance = solution.instance();
1525        assert_eq!(instance.network().buses().len(), 14);
1526        assert_eq!(instance.network().generators().len(), 5);
1527        let initial = instance.initial_point().expect("OPFData includes initials");
1528        let generator_id = instance.network().generators()[0]
1529            .uid
1530            .as_deref()
1531            .expect("parsed generators have stable identities");
1532        assert!((initial.generator_active_power(generator_id).unwrap() - 170.0).abs() < 1e-9);
1533        assert!((initial.generator_voltage_setpoint(generator_id).unwrap() - 1.0).abs() < 1e-12);
1534        assert!(solution.residuals().max_active_power_mismatch.unwrap() < 1.0);
1535        assert!(solution.residuals().max_reactive_power_mismatch.unwrap() < 1.0);
1536    }
1537
1538    #[test]
1539    fn malformed_opfdata_uses_the_universal_parse_error_path() {
1540        let error = parse(
1541            memory("broken.json", "{\"grid\": {}}")
1542                .with_format(powerio_core::FormatId::new("opfdata-json").unwrap()),
1543        )
1544        .expect_err("malformed OPFData");
1545        assert!(error.retained_source().is_some());
1546    }
1547
1548    const BMOPF_TINY: &str = r#"{
1549      "bus": {"a": {"terminal_names": ["1", "2", "3", "n"],
1550        "perfectly_grounded_terminals": ["n"]}},
1551      "voltage_source": {"s": {"bus": "a", "terminal_map": ["1", "2", "3"],
1552        "v_magnitude": [240.0, 240.0, 240.0], "v_angle": [0.0, -2.0944, 2.0944]}}
1553    }"#;
1554
1555    #[test]
1556    fn bmopf_parses_to_a_multiconductor_network() {
1557        // BMOPF shares the multiconductor network model. Callers construct a
1558        // power flow or optimal power flow instance explicitly afterward.
1559        let module = parse(memory("feeder.json", BMOPF_TINY)).expect("sniffed bmopf parses");
1560        assert_value_type(&module, "powerio.MulticonductorNetwork");
1561
1562        let module = parse(
1563            memory("<memory>", BMOPF_TINY)
1564                .with_format(powerio_core::FormatId::new("bmopf-json").unwrap()),
1565        )
1566        .expect("declared bmopf parses");
1567        assert_value_type(&module, "powerio.MulticonductorNetwork");
1568    }
1569
1570    #[test]
1571    fn nameless_json_text_routes_by_content() {
1572        // An in-memory source has no extension, so the family comes
1573        // from the document's own markers. Calculation and distribution
1574        // formats dispatch the same way they would from a `.json` file.
1575        let goc3 = fixture("../powerio-prob/tests/data/goc3_small.json");
1576        let module = parse(memory("<memory>", &goc3)).expect("nameless goc3 parses");
1577        assert_value_type(&module, "powerio.AcScucInstance");
1578
1579        let module = parse(memory("<memory>", BMOPF_TINY)).expect("nameless bmopf parses");
1580        assert_value_type(&module, "powerio.MulticonductorNetwork");
1581    }
1582
1583    #[test]
1584    fn a_declared_problem_format_that_fails_retains_the_source() {
1585        let error = parse(
1586            memory("broken.json", "{\"network\": {}}")
1587                .with_format(powerio_core::FormatId::new("goc3-json").unwrap()),
1588        )
1589        .expect_err("malformed goc3");
1590        assert!(error.retained_source().is_some());
1591    }
1592
1593    const PYPSA_STATIC: [(&str, &str); 4] = [
1594        ("network.csv", "name\nseq\n"),
1595        ("buses.csv", "name,v_nom\nB1,138.0\nB2,138.0\n"),
1596        ("loads.csv", "name,bus,p_set,q_set\nL1,B2,5.0,1.0\n"),
1597        (
1598            "generators.csv",
1599            "name,bus,control,p_nom,p_set\nG1,B1,Slack,100.0,12.0\n",
1600        ),
1601    ];
1602
1603    fn pypsa_folder(extra: &[(&str, &str)]) -> tempfile::TempDir {
1604        let temp = tempfile::tempdir().unwrap();
1605        for (name, content) in PYPSA_STATIC.iter().chain(extra) {
1606            std::fs::write(temp.path().join(name), content).unwrap();
1607        }
1608        temp
1609    }
1610
1611    #[test]
1612    fn a_pypsa_snapshot_parses_to_a_balanced_network() {
1613        let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n")]);
1614        let module =
1615            parse(powerio_core::Source::open(dir.path()).unwrap()).expect("snapshot parses");
1616        assert_value_type(&module, "powerio.BalancedNetwork");
1617    }
1618
1619    #[test]
1620    fn a_pypsa_input_series_parses_to_a_network_time_series() {
1621        let dir = pypsa_folder(&[
1622            ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1623            ("loads-p_set.csv", "snapshot,L1\nnow,10.0\nlater,20.0\n"),
1624        ]);
1625        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1626        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1627        assert!(module.source().is_some());
1628        let PioValue::TimeSeries(series) = &module.value() else {
1629            unreachable!();
1630        };
1631        assert_eq!(series.len(), 2);
1632        let PioValue::BalancedNetwork(later) = series.get(1).unwrap() else {
1633            unreachable!();
1634        };
1635        assert!((later.loads()[0].p - 20.0).abs() < 1e-12);
1636    }
1637
1638    #[test]
1639    fn a_pypsa_voltage_series_parses_to_operating_points() {
1640        let dir = pypsa_folder(&[
1641            ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1642            (
1643                "buses-v_mag_pu.csv",
1644                "snapshot,B1,B2\nnow,1.0,0.99\nlater,1.0,0.97\n",
1645            ),
1646            (
1647                "buses-v_ang.csv",
1648                "snapshot,B1,B2\nnow,0.0,-0.017453292519943295\nlater,0.0,-0.03490658503988659\n",
1649            ),
1650        ]);
1651        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1652        assert_value_type(
1653            &module,
1654            "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>",
1655        );
1656        let PioValue::TimeSeries(series) = &module.value() else {
1657            unreachable!();
1658        };
1659        let PioValue::BalancedOperatingPoint(later) = series.get(1).unwrap() else {
1660            unreachable!();
1661        };
1662        assert!((later.bus_voltage_magnitude(powerio_tx::BusId(2)).unwrap() - 0.97).abs() < 1e-12);
1663    }
1664
1665    #[test]
1666    fn a_pypsa_axis_with_no_series_stays_a_network_time_series() {
1667        // Several declared snapshots and nothing varying preserve the axis
1668        // as networks sharing every table; nothing here is an operating
1669        // point series.
1670        let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n1,later\n")]);
1671        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("axis parses");
1672        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1673    }
1674
1675    const EGRET_SERIES: &str = r#"{
1676        "model_name": "uc2",
1677        "elements": {
1678            "bus": {"1": {"matpower_bustype": "ref", "base_kv": 138.0},
1679                    "2": {"matpower_bustype": "PQ", "base_kv": 138.0}},
1680            "load": {"load_1": {"bus": "2",
1681                "p_load": {"data_type": "time_series", "values": [10.0, 20.0]},
1682                "q_load": 3.0}},
1683            "generator": {"1": {"bus": "1", "pg": 12.0, "qg": 0.0,
1684                "p_min": 0.0, "p_max": 50.0, "q_min": -10.0, "q_max": 10.0}},
1685            "branch": {"1": {"from_bus": "1", "to_bus": "2",
1686                "resistance": 0.01, "reactance": 0.1, "charging_susceptance": 0.0,
1687                "rating_long_term": 100.0, "rating_short_term": 100.0,
1688                "rating_emergency": 100.0, "transformer_phase_shift": 0.0}}
1689        },
1690        "system": {"baseMVA": 100.0, "time_keys": ["t1", "t2"]}
1691    }"#;
1692
1693    #[test]
1694    fn egret_time_keys_parse_to_a_network_time_series() {
1695        let module = parse(
1696            memory("uc2.json", EGRET_SERIES)
1697                .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1698        )
1699        .expect("egret series parses");
1700        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1701        assert!(module.source().is_some());
1702
1703        // The sniffed route agrees with the declared one.
1704        let module = parse(memory("uc2.json", EGRET_SERIES)).expect("sniffed egret parses");
1705        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1706    }
1707
1708    #[cfg(feature = "gridfm")]
1709    #[test]
1710    fn powerio_ir_uses_deserialize_not_parse() {
1711        use powerio_tx::{Bus, BusId, BusType};
1712        let network = powerio_tx::BalancedNetwork::in_memory(
1713            "stored",
1714            100.0,
1715            vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1716            vec![],
1717        );
1718        let original = powerio_core::PioModule::new(PioValue::BalancedNetwork(network));
1719        let emitted = serialize(&original, Destination::memory("case.pio.json").unwrap())
1720            .expect("module serializes");
1721        let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1722            unreachable!();
1723        };
1724        let module = deserialize(
1725            Source::from_memory("case.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1726        )
1727        .expect("module deserializes");
1728        assert_value_type(&module, "powerio.BalancedNetwork");
1729        assert!(module.source().is_some());
1730    }
1731
1732    #[cfg(feature = "gridfm")]
1733    #[test]
1734    fn a_gridfm_dataset_parses_to_a_scenario_set() {
1735        // Write a two scenario dataset with the matrix writer, then parse the
1736        // directory through the universal parse.
1737        let case = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
1738        let base = powerio_tx::parse(powerio_core::Source::open(case).unwrap())
1739            .expect("case9 parses")
1740            .into_value();
1741        let mut varied = base.clone();
1742        varied.loads_mut()[0].p += 5.0;
1743        let out = tempfile::tempdir().unwrap();
1744        let snapshots = [
1745            powerio_matrix::GridfmSnapshot::new(&base, 0),
1746            powerio_matrix::GridfmSnapshot::new(&varied, 1),
1747        ];
1748        powerio_matrix::emit_gridfm_batch(
1749            &snapshots,
1750            out.path(),
1751            &powerio_matrix::GridfmOptions::default(),
1752        )
1753        .expect("dataset writes");
1754
1755        let module =
1756            parse(powerio_core::Source::open(out.path()).unwrap()).expect("dataset parses");
1757        assert_value_type(&module, "powerio.ScenarioSet<powerio.BalancedNetwork>");
1758        assert!(module.source().is_some());
1759        let PioValue::ScenarioSet(set) = &module.value() else {
1760            unreachable!();
1761        };
1762        assert_eq!(set.len(), 2);
1763        assert!(set.get("0").is_some());
1764        assert!(set.get("1").is_some());
1765    }
1766
1767    #[test]
1768    fn an_unrecognized_directory_is_refused_with_the_hub_wording() {
1769        let dir = tempfile::tempdir().unwrap();
1770        std::fs::write(dir.path().join("notes.txt"), "not a case").unwrap();
1771        let error =
1772            parse(powerio_core::Source::open(dir.path()).unwrap()).expect_err("refused directory");
1773        assert!(error.to_string().contains("directory"), "{error}");
1774    }
1775
1776    #[test]
1777    fn a_scalar_egret_document_stays_a_balanced_network() {
1778        let scalar = EGRET_SERIES
1779            .replace(r#", "time_keys": ["t1", "t2"]"#, "")
1780            .replace(
1781                r#"{"data_type": "time_series", "values": [10.0, 20.0]}"#,
1782                "10.0",
1783            );
1784        let module = parse(
1785            memory("uc2.json", &scalar)
1786                .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1787        )
1788        .expect("scalar egret parses");
1789        assert_value_type(&module, "powerio.BalancedNetwork");
1790    }
1791}