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//! [`parse_with_options`] selects the parser explicitly and widens the
60//! directory a format may refer to further files beneath. [`Source`] and
61//! [`Destination`] remain the advanced input and output: a source carrying
62//! named buffers for a multi-file case in memory, and a memory destination
63//! with its artifact root name.
64//!
65//! ```no_run
66//! let module = powerio::parse_with_options(
67//!     "case.data",
68//!     &powerio::ParseOptions::default().format("psse")?,
69//! )?;
70//! # Ok::<(), Box<dyn std::error::Error>>(())
71//! ```
72
73/// The facade version recorded on producers and stored modules.
74pub const VERSION: &str = env!("CARGO_PKG_VERSION");
75
76/// The `schema` discriminator of every PowerIO IR document.
77pub const IR_SCHEMA_NAME: &str = "pio-ir";
78
79/// The PowerIO IR generation this build writes.
80///
81/// The generation is an integer that advances only when the serialized
82/// representation changes. It is independent of the PowerIO release, which
83/// the `producer` record of a document names, and of the C ABI version.
84///
85/// | Generation | First release | Change |
86/// |---|---|---|
87/// | 1 | v0.10.0 | the `PioModule` serialization, under the identity `powerio.module` |
88/// | 2 | v0.11.0 | the identity `pio-ir`; the producer release recorded apart from the generation; retained source bytes left out |
89///
90/// A bump within one minor release line ships with a reader for the
91/// generation it replaces, so every release of the line reads every
92/// generation the line wrote. [`IR_MIN_VERSION`] is the oldest generation
93/// this build reads.
94pub const IR_VERSION: u64 = 2;
95
96/// The oldest PowerIO IR generation this build reads.
97///
98/// The floor rises only at a minor release boundary. In 0.11 it equals
99/// [`IR_VERSION`].
100pub const IR_MIN_VERSION: u64 = 2;
101
102/// The `$id` of the JSON Schema for the documents this build writes, which is
103/// also the address the schema is served from.
104pub const IR_SCHEMA_ID: &str = "https://powerio.dev/schema/pio-ir/2/schema.json";
105
106use powerio_tx::format;
107pub use powerio_tx::{
108    Area, BalancedNetwork, Branch, BranchCharging, BranchCurrentRatings, BranchRatingSet,
109    BranchSolution, BranchSusceptanceFormula, Bus, BusId, BusType, Canvas, CoordinateSpace,
110    CoordsKind, DEFAULT_BASE_FREQUENCY, Detection, ElementKey, Extras, GenCaps, GenCost, Generator,
111    GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoMeta, GeoParsed, GeoTarget, Hvdc,
112    Impedance, IndexCore, IndexedNetwork, JSON_CLASSES, JsonClass, Load, LoadVoltageModel,
113    Location, PwdDisplay, PwdSubstation, Selector, Shunt, ShuntBlock, SolverParams, SourceFormat,
114    Storage, Switch, SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
115    TransformerControlMode, Winding, apply_substation_points, calc_series_admittance_of,
116    classify_json_bytes, classify_json_text, repair_values, to_geo_layer_from_pwd,
117    to_lonlat_from_pwd_mercator,
118};
119/// Balanced network records and the public network and geographic submodules.
120/// Derived indexes, normalization data, solver tables, and component error
121/// types remain available from `powerio-tx` rather than being duplicated at
122/// the facade root.
123pub use powerio_tx::{geo, network, version};
124
125pub use powerio_core::diagnostic_codes;
126/// The common module records and containers. These explicit facade exports
127/// keep ordinary callers out of the component crate paths.
128pub use powerio_core::{
129    ArtifactPath, ComponentId, Destination, Diagnostic, DiagnosticCode, DiagnosticId,
130    DiagnosticInfo, DiagnosticSeverity, DiagnosticStage, Digest, DigestAlgorithm, EmitResult,
131    EmittedOutput, Fidelity, FormatId, HistoryEntry, HistoryId, HistoryKind, MemoryArtifact,
132    OutputLayout, PioModule, Producer, Scenario, ScenarioId, ScenarioSet, Source, SourceBuffer,
133    SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan, StagedEdit, TimePoint,
134    TimeSeries,
135};
136
137/// The facade error covers source acquisition, routing, stored modules, and
138/// component failures converted at their boundary.
139pub use powerio_core::Error;
140pub type Result<T> = std::result::Result<T, powerio_core::Error>;
141
142/// Distribution types remain grouped under `powerio::dist` where their names
143/// overlap with balanced network types. Common unambiguous records are also
144/// available at the facade root.
145pub use powerio_dist as dist;
146pub use powerio_dist::{
147    BmopfEmitOptions, BmopfSchemaVersion, ConductorMatrix, DistGeoMeta, DistGraphEdgeKind,
148    MulticonductorNetwork,
149};
150
151pub use powerio_prob::solution::{SocwrOpfDuals, SocwrOpfSolution, SocwrOpfValues};
152/// The balanced calculation types used by solver consumers. The full problem
153/// vocabulary lives in [`powerio_prob`]; these types sit at the facade root so
154/// a consumer does not need a second PowerIO dependency to name its boundary.
155pub use powerio_prob::{
156    AcBusSpecification, AcOpfInstance, AcOpfSolution, AcPfInstance, AcPfSolution, AcScucInstance,
157    AcScucSolution, ActivePower, ActivePowerUnit, ApparentPower, ApparentPowerUnit,
158    BalancedCalculationInstance, CalculationUpdate, DcBusSpecification, DcOpfInstance,
159    DcOpfSolution, DcPfInstance, DcPfSolution, LoadAllocation, McAcOpfInstance, McAcOpfSolution,
160    McAcPfInstance, McAcPfSolution, NetworkUpdate, OperatingPointUpdate, ReactivePower,
161    ReactivePowerUnit, Termination, ThreeWindingTransformerTerminalActivePower,
162    ThreeWindingTransformerTerminalPower, UpdateChange, UpdateReport, UpdatedField,
163    apply_bus_load_active_power, apply_updates,
164};
165
166/// Matrix and graph data, re-exported from `powerio-matrix` under the
167/// `matrix` feature. Matrix construction is never a parse result, so the
168/// facade's automatic parsing and [`PioValue`] do not change with this
169/// feature.
170#[cfg(feature = "matrix")]
171pub use powerio_matrix as matrix;
172
173#[cfg(feature = "gridfm")]
174#[doc(hidden)]
175#[path = "gridfm.rs"]
176pub mod __gridfm;
177pub mod codes;
178mod formats;
179pub use formats::{FormatInfo, resolve_format};
180#[cfg(feature = "gridfm")]
181mod collect;
182pub mod dist_geo;
183#[cfg(feature = "gridfm")]
184pub use __gridfm::codes as gridfm_codes;
185mod stored;
186mod write;
187pub use write::emit;
188mod ir;
189#[cfg(feature = "schema")]
190pub use ir::generate_ir_schema;
191pub use ir::{deserialize, serialize, serialize_diagnostics};
192pub mod transform;
193pub use transform::{
194    apply_geo_layer, to_ac_opf_instance, to_ac_pf_instance, to_dc_opf_instance, to_dc_pf_instance,
195    to_mc_ac_opf_instance, to_mc_ac_pf_instance,
196};
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199enum Goc3DataFileKind {
200    Problem,
201    Solution,
202}
203
204#[derive(Default)]
205struct Goc3DataFiles {
206    problem: Option<SourceBuffer>,
207    solution: Option<SourceBuffer>,
208}
209
210impl Goc3DataFiles {
211    fn insert(&mut self, kind: Goc3DataFileKind, buffer: SourceBuffer) -> Result<()> {
212        let slot = match kind {
213            Goc3DataFileKind::Problem => &mut self.problem,
214            Goc3DataFileKind::Solution => &mut self.solution,
215        };
216        if let Some(existing) = slot {
217            return Err(Error::new(
218                &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
219                format!(
220                    "GO Challenge 3 source contains both `{}` and `{}` as {} data files",
221                    existing.name(),
222                    buffer.name(),
223                    match kind {
224                        Goc3DataFileKind::Problem => "problem",
225                        Goc3DataFileKind::Solution => "solution",
226                    }
227                ),
228            ));
229        }
230        *slot = Some(buffer);
231        Ok(())
232    }
233}
234
235/// The GO Challenge 3 root keys, read without building a document tree.
236#[derive(Default, serde::Deserialize)]
237struct Goc3Roots {
238    #[serde(default)]
239    network: Option<serde::de::IgnoredAny>,
240    #[serde(default)]
241    time_series_input: Option<serde::de::IgnoredAny>,
242    #[serde(default)]
243    reliability: Option<serde::de::IgnoredAny>,
244    #[serde(default)]
245    time_series_output: Option<serde::de::IgnoredAny>,
246}
247
248impl Goc3Roots {
249    fn is_problem(&self) -> bool {
250        self.network.is_some() && self.time_series_input.is_some() && self.reliability.is_some()
251    }
252
253    fn is_solution(&self) -> bool {
254        self.time_series_output.is_some()
255    }
256}
257
258/// The GO Challenge 3 root keys of a JSON document, or `None` for a well
259/// formed document whose root is not an object.
260fn goc3_roots(buffer: &SourceBuffer) -> Result<Option<Goc3Roots>> {
261    match serde_json::from_slice::<Goc3Roots>(buffer.content_bytes()) {
262        Ok(roots) => Ok(Some(roots)),
263        Err(error) if error.classify() == serde_json::error::Category::Data => Ok(None),
264        Err(error) => Err(Error::new(
265            &powerio_tx::diagnostics::codes::PARSE_GOC3_MALFORMED,
266            format!("{}: {error}", buffer.name()),
267        )),
268    }
269}
270
271fn goc3_file_kind(buffer: &SourceBuffer) -> Result<Option<Goc3DataFileKind>> {
272    let Some(roots) = goc3_roots(buffer)? else {
273        return Ok(None);
274    };
275    match (roots.is_problem(), roots.is_solution()) {
276        (true, false) => Ok(Some(Goc3DataFileKind::Problem)),
277        (false, true) => Ok(Some(Goc3DataFileKind::Solution)),
278        (false, false) => Ok(None),
279        (true, true) => Err(Error::new(
280            &powerio_tx::diagnostics::codes::READ_GOC3_AMBIGUOUS_DOCUMENTS,
281            format!(
282                "{} contains both the GO Challenge 3 problem and solution roots",
283                buffer.name()
284            ),
285        )),
286    }
287}
288
289fn goc3_data_files(source: &Source) -> Result<Goc3DataFiles> {
290    let mut buffers = if source.is_directory() {
291        let mut buffers = Vec::new();
292        for name in source.entry_names()? {
293            if std::path::Path::new(name.as_str())
294                .extension()
295                .and_then(|extension| extension.to_str())
296                .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
297            {
298                buffers.push(source.buffer(&name)?);
299            }
300        }
301        buffers
302    } else {
303        let mut buffers = vec![source.primary_buffer()?];
304        // `entry_names` succeeds here only for an in-memory source with named
305        // buffers. A file source never searches sibling files.
306        if let Ok(names) = source.entry_names() {
307            for name in names {
308                buffers.push(source.root_buffer(name.as_str())?);
309            }
310        }
311        buffers
312    };
313    buffers.sort_by(|left, right| left.name().cmp(right.name()));
314
315    let mut files = Goc3DataFiles::default();
316    for buffer in buffers {
317        if let Some(kind) = goc3_file_kind(&buffer)? {
318            files.insert(kind, buffer)?;
319        }
320    }
321    Ok(files)
322}
323
324fn directory_has_goc3_data(source: &Source) -> bool {
325    source.entry_names().is_ok_and(|names| {
326        names.into_iter().any(|name| {
327            std::path::Path::new(name.as_str())
328                .extension()
329                .and_then(|extension| extension.to_str())
330                .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
331                && source.buffer(&name).is_ok_and(|buffer| {
332                    goc3_roots(&buffer).is_ok_and(|roots| {
333                        roots.is_some_and(|roots| roots.is_solution() || roots.is_problem())
334                    })
335                })
336        })
337    })
338}
339
340/// Transform the `Substation` table in PowerWorld AUX text into a geographic
341/// layer without exposing the component parser's borrowed `AuxFile` type.
342///
343/// Rows without a finite number, latitude, and longitude are skipped. A valid
344/// AUX document with no usable substation coordinates returns an empty layer.
345///
346/// # Errors
347/// The AUX section syntax is malformed.
348pub fn to_geo_layer_from_aux_text(text: &str) -> Result<GeoLayer> {
349    let aux = powerio_tx::format::powerworld::aux_sections(text)
350        .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
351    Ok(powerio_tx::to_geo_layer_from_aux_substations(&aux))
352}
353
354/// A possibly partial assignment of instantaneous operating quantities over
355/// one network's fixed equipment identities.
356pub use powerio_prob::OperatingPoint;
357mod value;
358pub use value::{PioScenarioSet, PioTimeSeries, PioValue};
359
360/// Optional configuration for [`parse_with_options`]. Every field defaults to
361/// inference, so [`ParseOptions::default`] is what [`parse`] uses.
362#[derive(Clone, Debug, Default)]
363#[non_exhaustive]
364pub struct ParseOptions {
365    /// The parser selected by its stable format token rather than inferred
366    /// from the input's name and content.
367    pub format: Option<powerio_core::FormatId>,
368    /// The directory beneath which a format may refer to further files,
369    /// widening the default of the input file's own directory.
370    pub acquisition_root: Option<std::path::PathBuf>,
371}
372
373impl ParseOptions {
374    /// Select the parser by its stable format token.
375    ///
376    /// # Errors
377    /// `REQUEST.FORMAT.INVALID_ID` when the token is not a format identifier.
378    pub fn format(mut self, format: &str) -> std::result::Result<Self, powerio_core::Error> {
379        self.format = Some(powerio_core::FormatId::new(format)?);
380        Ok(self)
381    }
382
383    /// Select the parser by an already validated format identity.
384    #[must_use]
385    pub fn format_id(mut self, format: powerio_core::FormatId) -> Self {
386        self.format = Some(format);
387        self
388    }
389
390    /// Permit acquisition of files a format refers to beneath `root`.
391    #[must_use]
392    pub fn acquisition_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
393        self.acquisition_root = Some(root.into());
394        self
395    }
396}
397
398/// Parse one source into a compiled module of whichever built in family
399/// claims it. Balanced network formats produce
400/// [`PioValue::BalancedNetwork`]; network only distribution formats (OpenDSS
401/// `.dss`, PMD ENGINEERING JSON, and BMOPF JSON) produce
402/// [`PioValue::MulticonductorNetwork`]. A source that defines a particular
403/// calculation produces that calculation's value. One DOE GO Challenge 3
404/// problem data file produces [`PioValue::AcScucInstance`]. One source that
405/// contains a problem data file and its matching solution data file produces
406/// [`PioValue::AcScucSolution`]; a solution data file alone is rejected because
407/// its row identities and time axis come from the problem. DeepMind OPFData
408/// JSON, which explicitly represents a solved AC OPF, produces
409/// [`PioValue::AcOpfSolution`]. The parser's findings are the module's
410/// diagnostics, and the module keeps the original input, so writing the same
411/// format again returns the original file content.
412///
413/// The input is a file or directory name, content already in memory, or a
414/// [`powerio_core::Source`] carrying named buffers or a widened acquisition
415/// root. [`parse_with_options`] selects the parser explicitly. Content in
416/// memory carries the name [`powerio_core::MEMORY_SOURCE_NAME`], which
417/// identifies no format, so a format detected from a file extension rather
418/// than from the document itself is either declared through the options or
419/// named through [`powerio_core::Source::from_memory`].
420///
421/// The family comes from the input's declared format when one was selected,
422/// and otherwise from the name and content: a `.dss` extension routes to the
423/// distribution parser, a `.json` document routes by its top level markers
424/// ([`format::routing::classify_json_text`]), a name with no recognized
425/// extension whose content opens a JSON document (an in-memory source has no
426/// extension) routes the same way, and every other name routes to
427/// the balanced network hub, whose own detection and refusals apply.
428///
429/// PowerIO IR is not a grid exchange format: [`parse`] refuses it and
430/// [`deserialize`] reads the current PowerIO IR document.
431///
432/// # Errors
433/// The routed family's failure, carrying its findings and the retained
434/// source.
435pub fn parse(
436    input: impl powerio_core::IntoSource,
437) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
438    parse_with_options(input, &ParseOptions::default())
439}
440
441/// Parse one input under `options`, which selects the parser explicitly or
442/// widens the directory a format may refer to further files beneath.
443/// [`parse`] is this operation with the default options.
444///
445/// # Errors
446/// The input cannot be acquired, the format cannot be selected, or the routed
447/// family fails, each carrying its own diagnostic code.
448pub fn parse_with_options(
449    input: impl powerio_core::IntoSource,
450    options: &ParseOptions,
451) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
452    let mut source = input.into_source()?;
453    if let Some(root) = &options.acquisition_root {
454        source = source.with_acquisition_root(root.clone())?;
455    }
456    if let Some(format) = &options.format {
457        source = source.with_format(format.clone());
458    }
459    match routed_family(&source)? {
460        RoutedFamily::Goc3 => parse_goc3(source),
461        RoutedFamily::OpfData => powerio_prob::__internal::__decode_opfdata_solution(source)
462            .map(|module| module.map_value(PioValue::from)),
463        RoutedFamily::Distribution(detected) => {
464            let source = match (source.format(), detected) {
465                (None, Some(format)) => {
466                    source.with_format(powerio_core::FormatId::new(format.name())?)
467                }
468                _ => source,
469            };
470            powerio_dist::parse(source).map(|module| module.map_value(PioValue::from))
471        }
472        RoutedFamily::PypsaDirectory => parse_pypsa(source),
473        #[cfg(feature = "gridfm")]
474        RoutedFamily::Gridfm => parse_gridfm(source),
475        RoutedFamily::Egret => parse_egret(source),
476        RoutedFamily::Geo => parse_geo_layer(source),
477        RoutedFamily::Balanced(json_class) => format::parse_with_json_class(source, json_class)
478            .map(|module| module.map_value(PioValue::from)),
479    }
480}
481
482/// Parse the official GO Challenge 3 problem file, or a problem and its
483/// matching solution file supplied by one directory or one memory source.
484/// File roles come from the required top level JSON fields, not filenames.
485/// A solution file alone is incomplete because it contains neither the
486/// component definitions nor the time axis.
487fn parse_goc3(
488    source: powerio_core::Source,
489) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
490    let source = source.with_format(powerio_core::FormatId::new("goc3-json")?);
491    let files = match goc3_data_files(&source) {
492        Ok(files) => files,
493        Err(error) => return Err(error.with_source(source)),
494    };
495    let Some(problem) = files.problem else {
496        let message = if files.solution.is_some() {
497            "a GO Challenge 3 solution file requires the matching problem file in the same source"
498        } else {
499            "the source contains neither a GO Challenge 3 problem file nor a solution file"
500        };
501        return Err(Error::new(
502            &powerio_tx::diagnostics::codes::READ_GOC3_PROBLEM_REQUIRED,
503            message,
504        )
505        .with_source(source));
506    };
507
508    let (instance, diagnostics) =
509        match powerio_prob::__internal::__parse_goc3_problem_buffer(&problem) {
510            Ok(parsed) => parsed,
511            Err(error) => return Err(error.with_source(source)),
512        };
513    let value = match files.solution {
514        Some(solution) => {
515            let solution = match powerio_prob::__internal::__parse_goc3_output_buffer(
516                std::sync::Arc::new(instance),
517                &solution,
518            ) {
519                Ok(solution) => solution,
520                Err(error) => return Err(error.with_source(source)),
521            };
522            PioValue::from(solution)
523        }
524        None => PioValue::from(instance),
525    };
526    powerio_core::PioModule::parsed(value, source, diagnostics)
527}
528
529/// Read one standalone geographic document into [`PioValue::GeoLayer`]. A
530/// PowerWorld `.pwd` display lifts into a diagram space layer with substation
531/// targets; every other supported document is already a layer. The reader's
532/// notes on records it could not use become the module's diagnostics.
533fn parse_geo_layer(
534    source: powerio_core::Source,
535) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
536    let name = source.name().to_owned();
537    let declared = source.format().map(|format| format.as_str().to_owned());
538    let is_display = declared.as_deref().is_some_and(is_pwd_display_token)
539        || std::path::Path::new(&name)
540            .extension()
541            .and_then(|extension| extension.to_str())
542            .is_some_and(|extension| extension.eq_ignore_ascii_case("pwd"));
543
544    let buffer = match source.primary_buffer() {
545        Ok(buffer) => buffer,
546        Err(error) => return Err(error.with_source(source)),
547    };
548    let (layer, diagnostics) = if is_display {
549        match powerio_tx::format::powerworld::__parse_pwd_display(buffer.content_bytes()) {
550            Ok(display) => (powerio_tx::geo::to_geo_layer_from_pwd(&display), Vec::new()),
551            Err(error) => {
552                return Err(Error::new(error.code(), error.to_string())
553                    .with_cause(error)
554                    .with_source(source));
555            }
556        }
557    } else {
558        let text = match std::str::from_utf8(buffer.content_bytes()) {
559            Ok(text) => text,
560            Err(cause) => {
561                return Err(Error::new(
562                    &powerio_tx::diagnostics::codes::READ_GEO_NOT_TEXT,
563                    format!("a geographic layer document is not valid UTF-8: {cause}"),
564                )
565                .with_source(source));
566            }
567        };
568        match powerio_tx::geo::GeoLayer::parse(
569            text,
570            std::path::Path::new(&name)
571                .file_name()
572                .and_then(|name| name.to_str()),
573        ) {
574            Ok(parsed) => (parsed.layer, parsed.diagnostics),
575            Err(error) => {
576                return Err(Error::new(error.code(), error.to_string())
577                    .with_cause(error)
578                    .with_source(source));
579            }
580        }
581    };
582    let source = match declared {
583        Some(_) => source,
584        None => source.with_format(powerio_core::FormatId::new(if is_display {
585            "powerworld-pwd"
586        } else {
587            "geo-json"
588        })?),
589    };
590    powerio_core::PioModule::parsed(PioValue::from(layer), source, diagnostics)
591}
592
593/// PyPSA CSV dispatch: one snapshot with no series siblings is the scalar
594/// profile through the balanced hub; a declared axis routes to the sequence
595/// parser, producing a network series or, when only operating quantities
596/// vary, an operating point series over one shared network.
597fn parse_pypsa(
598    source: powerio_core::Source,
599) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
600    if !source.is_directory() {
601        // A file claiming the PyPSA token gets the hub's own refusal wording.
602        return format::parse(source).map(|module| module.map_value(PioValue::from));
603    }
604    // Directory routing identified the source before the typed reader runs.
605    // Record that decision on an undeclared source so same format emission
606    // can distinguish a PyPSA directory from the GridFM directory family.
607    let source = match source.format() {
608        Some(_) => source,
609        None => source.with_format(powerio_core::FormatId::new("pypsa-csv")?),
610    };
611    let axis = match format::__pypsa_axis(&source) {
612        Ok(axis) => axis,
613        Err(error) => {
614            let core = powerio_core::Error::new(error.code(), error.to_string());
615            return Err(core.with_source(source));
616        }
617    };
618    match axis {
619        format::PypsaAxis::SingleSnapshot => {
620            format::parse(source).map(|module| module.map_value(PioValue::from))
621        }
622        format::PypsaAxis::Series => {
623            match powerio_prob::__internal::__decode_pypsa_sequence(&source) {
624                Ok((sequence, diagnostics)) => {
625                    let value = match sequence {
626                        powerio_prob::__internal::PypsaSequence::Networks(series) => {
627                            PioValue::from(series)
628                        }
629                        powerio_prob::__internal::PypsaSequence::OperatingPoints(points) => {
630                            PioValue::from(points)
631                        }
632                    };
633                    powerio_core::PioModule::parsed(value, source, diagnostics)
634                }
635                Err(error) => Err(error.with_source(source)),
636            }
637        }
638    }
639}
640
641/// gridfm dispatch: every scenario of the Parquet dataset as one scenario
642/// set over shared element identities.
643#[cfg(feature = "gridfm")]
644fn parse_gridfm(
645    source: powerio_core::Source,
646) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
647    if !source.is_directory() {
648        // A file claiming the gridfm token gets the hub's own refusal wording.
649        return format::parse(source).map(|module| module.map_value(PioValue::from));
650    }
651    let source = match source.format() {
652        Some(_) => source,
653        None => source.with_format(powerio_core::FormatId::new("gridfm")?),
654    };
655    match __gridfm::parse_gridfm_source(&source) {
656        Ok((set, diagnostics)) => {
657            powerio_core::PioModule::parsed(PioValue::from(set), source, diagnostics)
658        }
659        Err(error) => Err(error.with_source(source)),
660    }
661}
662
663/// Egret dispatch: a document declaring `system.time_keys` routes to the
664/// sequence parser and produces a balanced network time series; a scalar
665/// document routes through the balanced hub.
666fn parse_egret(
667    source: powerio_core::Source,
668) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
669    let declares_series = {
670        let buffer = source.primary_buffer()?;
671        std::str::from_utf8(buffer.content_bytes()).is_ok_and(format::__egret_declares_time_series)
672    };
673    if !declares_series {
674        return format::parse(source).map(|module| module.map_value(PioValue::from));
675    }
676    let parsed = {
677        let buffer = source.primary_buffer()?;
678        let stem = std::path::Path::new(source.name())
679            .file_stem()
680            .and_then(|stem| stem.to_str())
681            .map(str::to_owned);
682        match std::str::from_utf8(buffer.content_bytes()) {
683            Ok(text) => format::__parse_egret_time_series(text, stem.as_deref())
684                .map_err(|error| powerio_core::Error::new(error.code(), error.to_string())),
685            Err(error) => {
686                let cause = powerio_tx::Error::FormatRead {
687                    format: "case text",
688                    message: format!("not valid UTF-8: {error}"),
689                };
690                Err(powerio_core::Error::new(cause.code(), cause.to_string()))
691            }
692        }
693    };
694    match parsed {
695        Ok(series) => powerio_core::PioModule::parsed(PioValue::from(series), source, Vec::new()),
696        Err(error) => Err(error.with_source(source)),
697    }
698}
699
700/// The family a source routes to. The balanced hub is the default: it owns
701/// the guidance for unknown names and refused shapes. `Balanced` carries the
702/// JSON classification when routing here was itself the result of one (so
703/// the balanced hub does not classify the same text a second time); `None`
704/// when the source routed here by extension, by a declared token, or by a
705/// directory shape, none of which run a JSON classification at all.
706enum RoutedFamily {
707    Balanced(Option<format::routing::JsonClass>),
708    Distribution(Option<format::routing::DistributionFormat>),
709    Goc3,
710    OpfData,
711    PypsaDirectory,
712    Egret,
713    /// A standalone geographic document: the canonical `.geo.json`, GeoJSON,
714    /// aliased CSV or JSON records, headerless buscoords CSV, or a PowerWorld
715    /// `.pwd` display lifted into a diagram space layer.
716    Geo,
717    #[cfg(feature = "gridfm")]
718    Gridfm,
719}
720
721fn routed_family(
722    source: &powerio_core::Source,
723) -> std::result::Result<RoutedFamily, powerio_core::Error> {
724    if let Some(declared) = source.format() {
725        return Ok(family_of_token(declared.as_str()));
726    }
727    if source.is_directory() {
728        // GOC3 pairs are identified by their official JSON roots rather than
729        // filenames. The format parser performs the exact cardinality and
730        // schema checks after routing.
731        if directory_has_goc3_data(source) {
732            return Ok(RoutedFamily::Goc3);
733        }
734        // PyPSA is a CSV folder containing network.csv. GridFM is a Parquet
735        // dataset with bus_data.parquet at one of its documented locations.
736        // Anything else falls to the balanced hub's refusal.
737        let marker = powerio_core::ArtifactPath::new("network.csv")
738            .expect("static name is a valid artifact path");
739        if source.buffer(&marker).is_ok() {
740            return Ok(RoutedFamily::PypsaDirectory);
741        }
742        #[cfg(feature = "gridfm")]
743        if let Ok(entries) = source.entry_names()
744            && entries.iter().any(|entry| {
745                entry.as_str().ends_with("bus_data.parquet")
746                    && matches!(entry.as_str().matches('/').count(), 0..=2)
747            })
748        {
749            return Ok(RoutedFamily::Gridfm);
750        }
751        return Ok(RoutedFamily::Balanced(None));
752    }
753    let extension = std::path::Path::new(source.name())
754        .extension()
755        .and_then(|extension| extension.to_str())
756        .unwrap_or_default()
757        .to_ascii_lowercase();
758    if has_geo_layer_extension(source.name()) {
759        return Ok(RoutedFamily::Geo);
760    }
761    match extension.as_str() {
762        "dss" => Ok(RoutedFamily::Distribution(Some(
763            format::routing::DistributionFormat::Dss,
764        ))),
765        "json" => json_family(source),
766        // Extensions with dedicated non-JSON readers keep them; anything
767        // else (a nameless in-memory source above all) can still carry a
768        // JSON document, so content that opens one routes by classification,
769        // mirroring the balanced hub's own sniff.
770        "pwd" | "geojson" => Ok(RoutedFamily::Geo),
771        "m" | "raw" | "aux" | "epc" | "pwb" | "uct" => Ok(RoutedFamily::Balanced(None)),
772        _ => {
773            let jsonish = source.primary_buffer().is_ok_and(|buffer| {
774                std::str::from_utf8(buffer.content_bytes()).is_ok_and(|text| {
775                    // Strip a UTF-8 BOM the way the JSON classifier does, so
776                    // a BOM-prefixed nameless document routes the same as
777                    // the identical content saved with a .json name.
778                    text.trim_start_matches('\u{feff}')
779                        .trim_start()
780                        .starts_with(['{', '['])
781                })
782            });
783            if jsonish {
784                json_family(source)
785            } else {
786                Ok(RoutedFamily::Balanced(None))
787            }
788        }
789    }
790}
791
792/// Whether `name` carries the compound `geo.json` extension: the whole name,
793/// or the name after a separator, so `layer.geo.json`, `layer_geo.json`, and
794/// `layer-geo.json` all state a layer. A stem that merely ends in the same
795/// letters (`apogeo.json`) does not, and keeps its JSON classification, which
796/// matters because JSON content classification has no layer verdict and would
797/// refuse the file as an unrecognized case.
798fn has_geo_layer_extension(name: &str) -> bool {
799    let name = name.to_ascii_lowercase();
800    let extension = powerio_tx::geo::GEO_LAYER_EXTENSION;
801    name == extension
802        || name
803            .strip_suffix(extension)
804            .is_some_and(|stem| stem.ends_with(['.', '_', '-', '/', '\\']))
805}
806
807/// Whether `token` names the standalone geographic layer document.
808pub(crate) fn is_geo_layer_token(token: &str) -> bool {
809    matches!(
810        token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
811        "geojson" | "geo" | "geolayer"
812    )
813}
814
815/// Whether `token` names a PowerWorld display file, which reads as a diagram
816/// space layer.
817pub(crate) fn is_pwd_display_token(token: &str) -> bool {
818    matches!(
819        token.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
820        "pwd" | "powerworldpwd" | "powerworlddisplay"
821    )
822}
823
824/// Whether `token` names a document that reads into [`PioValue::GeoLayer`].
825fn is_geo_token(token: &str) -> bool {
826    is_geo_layer_token(token) || is_pwd_display_token(token)
827}
828
829/// The family a JSON document's content markers select.
830fn json_family(
831    source: &powerio_core::Source,
832) -> std::result::Result<RoutedFamily, powerio_core::Error> {
833    use format::routing::{Detection, JsonClass, SourceFormat, TransmissionFormat};
834
835    let buffer = source.primary_buffer()?;
836    // Family routing needs decoded text; a non-UTF-8 `.json` fails in
837    // the balanced hub with its own wording. Classification never ran, so
838    // the balanced hub gets no hint and classifies it itself.
839    let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
840        return Ok(RoutedFamily::Balanced(None));
841    };
842    let class = format::routing::classify_json_text(text);
843    match class {
844        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
845            TransmissionFormat::Goc3Json,
846        ))) => Ok(RoutedFamily::Goc3),
847        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
848            TransmissionFormat::DeepMindOpfDataJson,
849        ))) => Ok(RoutedFamily::OpfData),
850        JsonClass::Case(Detection::Known(SourceFormat::Transmission(
851            TransmissionFormat::EgretJson,
852        ))) => Ok(RoutedFamily::Egret),
853        JsonClass::Case(Detection::Known(SourceFormat::Distribution(format))) => {
854            Ok(RoutedFamily::Distribution(Some(format)))
855        }
856        JsonClass::Module => Err(powerio_core::Error::new(
857            &codes::REQUEST_PARSE_POWERIO_IR,
858            "PowerIO IR is not a grid exchange format; call deserialize(source)",
859        )),
860        // The balanced hub owns the refusal wording for unrecognized or
861        // ambiguous documents. Pass the classification through so it does not
862        // inspect the same bytes twice.
863        JsonClass::Case(Detection::Known(_) | Detection::Ambiguous | Detection::Unknown) => {
864            Ok(RoutedFamily::Balanced(Some(class)))
865        }
866    }
867}
868
869/// The family a declared format token selects. Unknown tokens fall to the
870/// balanced hub, which owns the refusal wording and the accepted name list.
871fn family_of_token(token: &str) -> RoutedFamily {
872    use format::TargetFormat;
873
874    if is_geo_token(token) {
875        return RoutedFamily::Geo;
876    }
877
878    if powerio_dist::parse_dist_target_format(token).is_some() {
879        return RoutedFamily::Distribution(None);
880    }
881    if format::is_pypsa_csv_name(token) {
882        return RoutedFamily::PypsaDirectory;
883    }
884    #[cfg(feature = "gridfm")]
885    if token.eq_ignore_ascii_case("gridfm") {
886        return RoutedFamily::Gridfm;
887    }
888    match format::parse_target_format(token) {
889        Some(TargetFormat::Goc3Json) => RoutedFamily::Goc3,
890        Some(TargetFormat::DeepMindOpfDataJson) => RoutedFamily::OpfData,
891        Some(TargetFormat::EgretJson) => RoutedFamily::Egret,
892        _ => RoutedFamily::Balanced(None),
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    fn memory(name: &str, text: &str) -> powerio_core::Source {
901        powerio_core::Source::from_memory(name, text.as_bytes().to_vec()).expect("memory source")
902    }
903
904    fn parse(
905        source: powerio_core::Source,
906    ) -> std::result::Result<powerio_core::PioModule<PioValue>, powerio_core::Error> {
907        super::parse(source)
908    }
909
910    fn options(format: Option<&str>) -> ParseOptions {
911        match format {
912            Some(format) => ParseOptions::default().format(format).unwrap(),
913            None => ParseOptions::default(),
914        }
915    }
916
917    fn assert_value_type(module: &powerio_core::PioModule<PioValue>, expected: &str) {
918        assert_eq!(module.value().type_name(), expected);
919    }
920
921    #[test]
922    fn a_matpower_source_parses_to_a_balanced_network() {
923        let case = "function mpc = case\n\
924                    mpc.version = '2';\n\
925                    mpc.baseMVA = 100;\n\
926                    mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
927                    mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
928                    mpc.branch = [];\n";
929        let module = parse(
930            memory("case.m", case).with_format(powerio_core::FormatId::new("matpower").unwrap()),
931        )
932        .expect("matpower parses");
933        assert_value_type(&module, "powerio.BalancedNetwork");
934    }
935
936    #[test]
937    fn memory_parse_retains_its_name_and_optional_format() {
938        let case = "function mpc = inline\n\
939                    mpc.version = '2';\n\
940                    mpc.baseMVA = 100;\n\
941                    mpc.bus = [1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;];\n\
942                    mpc.gen = [1 0 0 10 -10 1 100 1 10 0;];\n\
943                    mpc.branch = [];\n";
944
945        let detected = super::parse(memory("inline-case.m", case)).expect("name detects MATPOWER");
946        assert_eq!(detected.source().unwrap().name(), "inline-case.m");
947        assert_eq!(
948            detected.source().unwrap().format().map(FormatId::as_str),
949            Some("matpower")
950        );
951
952        let declared = super::parse_with_options(
953            memory("consumer-input", case),
954            &ParseOptions::default().format("matpower").unwrap(),
955        )
956        .expect("declared MATPOWER");
957        let source = declared.source().expect("source retained");
958        assert_eq!(source.name(), "consumer-input");
959        assert_eq!(source.format().map(FormatId::as_str), Some("matpower"));
960    }
961
962    #[test]
963    fn universal_parse_reads_declared_iso_8859_1_xiidm_and_retains_exact_bytes() {
964        let text = r#"<?xml version="1.0" encoding="ISO-8859-1"?>
965<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_17" id="case" caseDate="2026-01-01T00:00:00Z" forecastDistance="0" sourceFormat="Réseau PowSybl" minimumValidationLevel="STEADY_STATE_HYPOTHESIS">
966  <iidm:voltageLevel id="VL" nominalV="225" topologyKind="BUS_BREAKER">
967    <iidm:busBreakerTopology><iidm:bus id="B" v="225" angle="0"/></iidm:busBreakerTopology>
968    <iidm:generator id="G" energySource="OTHER" minP="0" maxP="100" voltageRegulatorOn="true" targetP="50" targetV="225" bus="B" connectableBus="B"><iidm:minMaxReactiveLimits minQ="-20" maxQ="20"/></iidm:generator>
969  </iidm:voltageLevel>
970</iidm:network>"#;
971        let bytes: Vec<u8> = text
972            .chars()
973            .map(|value| u8::try_from(u32::from(value)).expect("fixture is ISO-8859-1"))
974            .collect();
975        assert!(std::str::from_utf8(&bytes).is_err());
976
977        for (name, format) in [
978            ("case.xiidm", None),
979            ("case.xml", None),
980            ("memory", Some("xiidm")),
981        ] {
982            let source = Source::from_memory(name, bytes.clone()).unwrap();
983            let module = super::parse_with_options(source, &options(format)).unwrap();
984            let PioValue::BalancedNetwork(network) = &module.value() else {
985                panic!(
986                    "expected BalancedNetwork, got {}",
987                    module.value().type_name()
988                );
989            };
990            assert_eq!(
991                network.case_metadata().source_model_format.as_deref(),
992                Some("Réseau PowSybl")
993            );
994            let retained = module.source().unwrap();
995            assert_eq!(retained.format().map(FormatId::as_str), Some("xiidm"));
996            assert_eq!(retained.primary_buffer().unwrap().bytes(), bytes);
997
998            let emitted =
999                emit(&module, "xiidm", Destination::memory("copy.xiidm").unwrap()).unwrap();
1000            assert_eq!(emitted.fidelity(), Fidelity::ExactSameFormat);
1001            let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1002                panic!("memory destination returned a path output");
1003            };
1004            assert_eq!(artifacts.len(), 1);
1005            assert_eq!(artifacts[0].bytes(), bytes);
1006        }
1007    }
1008
1009    #[test]
1010    fn a_dss_source_parses_to_a_multiconductor_network() {
1011        let module = parse(memory(
1012            "feeder.dss",
1013            "New Circuit.c basekv=12.47 bus1=src\n",
1014        ))
1015        .expect("dss parses");
1016        let PioValue::MulticonductorNetwork(network) = &module.value() else {
1017            panic!(
1018                "expected multiconductor network, got {}",
1019                module.value().type_name()
1020            );
1021        };
1022        assert_eq!(network.name().as_deref(), Some("c"));
1023    }
1024
1025    #[test]
1026    fn a_declared_distribution_format_routes_without_an_extension() {
1027        let module = parse(
1028            memory("<memory>", "New Circuit.c basekv=12.47 bus1=src\n")
1029                .with_format(powerio_core::FormatId::new("dss").unwrap()),
1030        )
1031        .expect("declared dss parses");
1032        assert_value_type(&module, "powerio.MulticonductorNetwork");
1033    }
1034
1035    #[test]
1036    fn json_routes_by_top_level_markers() {
1037        // A PMD document carries `data_model`, which no balanced format does.
1038        let module = parse(memory(
1039            "feeder.json",
1040            r#"{"data_model": "ENGINEERING", "bus": {}}"#,
1041        ))
1042        .expect("pmd parses");
1043        assert_value_type(&module, "powerio.MulticonductorNetwork");
1044    }
1045
1046    #[test]
1047    fn a_bare_network_object_is_not_powerio_ir_or_a_case_format() {
1048        let error = parse(memory(
1049            "net.json",
1050            r#"{"name":"network","base_mva":100.0,"buses":[],"branches":[]}"#,
1051        ))
1052        .expect_err("an unmarked network object must not parse");
1053        assert!(error.to_string().contains("cannot infer JSON format"));
1054    }
1055
1056    #[test]
1057    fn the_error_path_retains_the_source() {
1058        let error = parse(memory("case.m", "not matpower at all")).expect_err("malformed");
1059        assert!(error.retained_source().is_some());
1060    }
1061
1062    fn fixture(path: &str) -> String {
1063        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1064        std::fs::read_to_string(root.join(path)).unwrap()
1065    }
1066
1067    #[test]
1068    fn goc3_parses_to_an_scuc_instance() {
1069        // A calculation defining source produces its calculation's value,
1070        // never the bare network its data could also build.
1071        let text = fixture("../powerio-prob/tests/data/goc3_small.json");
1072        let module = parse(memory("goc3_small.json", &text)).expect("goc3 parses");
1073        assert_value_type(&module, "powerio.AcScucInstance");
1074        assert!(module.source().is_some());
1075        let PioValue::AcScucInstance(instance) = &module.value() else {
1076            unreachable!();
1077        };
1078        assert_eq!(instance.network().buses().len(), 2);
1079    }
1080
1081    #[test]
1082    fn goc3_problem_and_solution_parse_with_the_one_public_operation() {
1083        let problem = fixture("../tests/data/goc3/goc3_small.json");
1084        let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1085        let source = powerio_core::Source::from_memory("problem.json", problem.into_bytes())
1086            .unwrap()
1087            .with_named_buffer("solution.json", solution.into_bytes())
1088            .unwrap();
1089        let module = parse(source).expect("problem and solution parse together");
1090        let PioValue::AcScucSolution(solution) = &module.value() else {
1091            panic!(
1092                "expected AC SCUC solution, got {}",
1093                module.value().type_name()
1094            );
1095        };
1096        assert_eq!(solution.instance().network().buses().len(), 2);
1097        assert_eq!(
1098            solution.network_outputs().shunt_step,
1099            vec![vec![1], vec![2]]
1100        );
1101        assert_eq!(module.sources().len(), 2);
1102
1103        let emitted = emit(
1104            &module,
1105            "goc3-json",
1106            Destination::memory("solution.json").unwrap(),
1107        )
1108        .expect("solution emits as official GOC3 output");
1109        let EmittedOutput::Memory { artifacts } = emitted.output() else {
1110            unreachable!();
1111        };
1112        assert_eq!(artifacts.len(), 1);
1113        let document: serde_json::Value = serde_json::from_slice(artifacts[0].bytes()).unwrap();
1114        assert!(document.get("time_series_output").is_some());
1115        assert!(document.get("network").is_none());
1116    }
1117
1118    #[test]
1119    fn goc3_solution_alone_names_the_missing_problem() {
1120        let solution = fixture("../tests/data/goc3/goc3_small_solution.json");
1121        let error = parse(memory("solution.json", &solution))
1122            .expect_err("a solution without its problem is incomplete");
1123        assert!(error.to_string().contains("matching problem file"));
1124        assert!(error.retained_source().is_some());
1125    }
1126
1127    #[test]
1128    fn opfdata_parses_to_an_ac_opf_solution() {
1129        let text = fixture("../tests/data/opfdataset/example_0.json");
1130        let module = parse(memory("example_0.json", &text)).expect("opfdata parses");
1131        let PioValue::AcOpfSolution(solution) = &module.value() else {
1132            panic!(
1133                "expected AC OPF solution, got {}",
1134                module.value().type_name()
1135            );
1136        };
1137        assert_eq!(
1138            module
1139                .sources()
1140                .first()
1141                .and_then(|source| source.format())
1142                .map(powerio_core::FormatId::as_str),
1143            Some("opfdata-json")
1144        );
1145        assert_eq!(
1146            *solution.termination(),
1147            powerio_prob::Termination::NotReported
1148        );
1149        assert!((solution.objective() - 2_265.953_939_003_096).abs() < 1e-9);
1150
1151        let instance = solution.instance();
1152        assert_eq!(instance.network().buses().len(), 14);
1153        assert_eq!(instance.network().generators().len(), 5);
1154        let initial = instance.initial_point().expect("OPFData includes initials");
1155        let generator_id = instance.network().generators()[0]
1156            .uid
1157            .as_deref()
1158            .expect("parsed generators have stable identities");
1159        assert!((initial.generator_active_power(generator_id).unwrap() - 170.0).abs() < 1e-9);
1160        assert!((initial.generator_voltage_setpoint(generator_id).unwrap() - 1.0).abs() < 1e-12);
1161        assert!(solution.residuals().max_active_power_mismatch.unwrap() < 1.0);
1162        assert!(solution.residuals().max_reactive_power_mismatch.unwrap() < 1.0);
1163    }
1164
1165    #[test]
1166    fn malformed_opfdata_uses_the_universal_parse_error_path() {
1167        let error = parse(
1168            memory("broken.json", "{\"grid\": {}}")
1169                .with_format(powerio_core::FormatId::new("opfdata-json").unwrap()),
1170        )
1171        .expect_err("malformed OPFData");
1172        assert!(error.retained_source().is_some());
1173    }
1174
1175    const BMOPF_TINY: &str = r#"{
1176      "bus": {"a": {"terminal_names": ["1", "2", "3", "n"],
1177        "perfectly_grounded_terminals": ["n"]}},
1178      "voltage_source": {"s": {"bus": "a", "terminal_map": ["1", "2", "3"],
1179        "v_magnitude": [240.0, 240.0, 240.0], "v_angle": [0.0, -2.0944, 2.0944]}}
1180    }"#;
1181
1182    #[test]
1183    fn bmopf_parses_to_a_multiconductor_network() {
1184        // BMOPF shares the multiconductor network model. Callers construct a
1185        // power flow or optimal power flow instance explicitly afterward.
1186        let module = parse(memory("feeder.json", BMOPF_TINY)).expect("sniffed bmopf parses");
1187        assert_value_type(&module, "powerio.MulticonductorNetwork");
1188
1189        let module = parse(
1190            memory("<memory>", BMOPF_TINY)
1191                .with_format(powerio_core::FormatId::new("bmopf-json").unwrap()),
1192        )
1193        .expect("declared bmopf parses");
1194        assert_value_type(&module, "powerio.MulticonductorNetwork");
1195    }
1196
1197    #[test]
1198    fn nameless_json_text_routes_by_content() {
1199        // An in-memory source has no extension, so the family comes
1200        // from the document's own markers. Calculation and distribution
1201        // formats dispatch the same way they would from a `.json` file.
1202        let goc3 = fixture("../powerio-prob/tests/data/goc3_small.json");
1203        let module = parse(memory("<memory>", &goc3)).expect("nameless goc3 parses");
1204        assert_value_type(&module, "powerio.AcScucInstance");
1205
1206        let module = parse(memory("<memory>", BMOPF_TINY)).expect("nameless bmopf parses");
1207        assert_value_type(&module, "powerio.MulticonductorNetwork");
1208    }
1209
1210    #[test]
1211    fn a_declared_problem_format_that_fails_retains_the_source() {
1212        let error = parse(
1213            memory("broken.json", "{\"network\": {}}")
1214                .with_format(powerio_core::FormatId::new("goc3-json").unwrap()),
1215        )
1216        .expect_err("malformed goc3");
1217        assert!(error.retained_source().is_some());
1218    }
1219
1220    const PYPSA_STATIC: [(&str, &str); 4] = [
1221        ("network.csv", "name\nseq\n"),
1222        ("buses.csv", "name,v_nom\nB1,138.0\nB2,138.0\n"),
1223        ("loads.csv", "name,bus,p_set,q_set\nL1,B2,5.0,1.0\n"),
1224        (
1225            "generators.csv",
1226            "name,bus,control,p_nom,p_set\nG1,B1,Slack,100.0,12.0\n",
1227        ),
1228    ];
1229
1230    fn pypsa_folder(extra: &[(&str, &str)]) -> tempfile::TempDir {
1231        let temp = tempfile::tempdir().unwrap();
1232        for (name, content) in PYPSA_STATIC.iter().chain(extra) {
1233            std::fs::write(temp.path().join(name), content).unwrap();
1234        }
1235        temp
1236    }
1237
1238    #[test]
1239    fn a_pypsa_snapshot_parses_to_a_balanced_network() {
1240        let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n")]);
1241        let module =
1242            parse(powerio_core::Source::open(dir.path()).unwrap()).expect("snapshot parses");
1243        assert_value_type(&module, "powerio.BalancedNetwork");
1244    }
1245
1246    #[test]
1247    fn a_pypsa_input_series_parses_to_a_network_time_series() {
1248        let dir = pypsa_folder(&[
1249            ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1250            ("loads-p_set.csv", "snapshot,L1\nnow,10.0\nlater,20.0\n"),
1251        ]);
1252        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1253        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1254        assert!(module.source().is_some());
1255        let PioValue::TimeSeries(series) = &module.value() else {
1256            unreachable!();
1257        };
1258        assert_eq!(series.len(), 2);
1259        let PioValue::BalancedNetwork(later) = series.get(1).unwrap() else {
1260            unreachable!();
1261        };
1262        assert!((later.loads()[0].p - 20.0).abs() < 1e-12);
1263    }
1264
1265    #[test]
1266    fn a_pypsa_voltage_series_parses_to_operating_points() {
1267        let dir = pypsa_folder(&[
1268            ("snapshots.csv", ",snapshot\n0,now\n1,later\n"),
1269            (
1270                "buses-v_mag_pu.csv",
1271                "snapshot,B1,B2\nnow,1.0,0.99\nlater,1.0,0.97\n",
1272            ),
1273            (
1274                "buses-v_ang.csv",
1275                "snapshot,B1,B2\nnow,0.0,-0.017453292519943295\nlater,0.0,-0.03490658503988659\n",
1276            ),
1277        ]);
1278        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("series parses");
1279        assert_value_type(
1280            &module,
1281            "powerio.TimeSeries<powerio.OperatingPoint<powerio.BalancedNetwork>>",
1282        );
1283        let PioValue::TimeSeries(series) = &module.value() else {
1284            unreachable!();
1285        };
1286        let PioValue::BalancedOperatingPoint(later) = series.get(1).unwrap() else {
1287            unreachable!();
1288        };
1289        assert!((later.bus_voltage_magnitude(powerio_tx::BusId(2)).unwrap() - 0.97).abs() < 1e-12);
1290    }
1291
1292    #[test]
1293    fn a_pypsa_axis_with_no_series_stays_a_network_time_series() {
1294        // Several declared snapshots and nothing varying preserve the axis
1295        // as networks sharing every table; nothing here is an operating
1296        // point series.
1297        let dir = pypsa_folder(&[("snapshots.csv", ",snapshot\n0,now\n1,later\n")]);
1298        let module = parse(powerio_core::Source::open(dir.path()).unwrap()).expect("axis parses");
1299        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1300    }
1301
1302    const EGRET_SERIES: &str = r#"{
1303        "model_name": "uc2",
1304        "elements": {
1305            "bus": {"1": {"matpower_bustype": "ref", "base_kv": 138.0},
1306                    "2": {"matpower_bustype": "PQ", "base_kv": 138.0}},
1307            "load": {"load_1": {"bus": "2",
1308                "p_load": {"data_type": "time_series", "values": [10.0, 20.0]},
1309                "q_load": 3.0}},
1310            "generator": {"1": {"bus": "1", "pg": 12.0, "qg": 0.0,
1311                "p_min": 0.0, "p_max": 50.0, "q_min": -10.0, "q_max": 10.0}},
1312            "branch": {"1": {"from_bus": "1", "to_bus": "2",
1313                "resistance": 0.01, "reactance": 0.1, "charging_susceptance": 0.0,
1314                "rating_long_term": 100.0, "rating_short_term": 100.0,
1315                "rating_emergency": 100.0, "transformer_phase_shift": 0.0}}
1316        },
1317        "system": {"baseMVA": 100.0, "time_keys": ["t1", "t2"]}
1318    }"#;
1319
1320    #[test]
1321    fn egret_time_keys_parse_to_a_network_time_series() {
1322        let module = parse(
1323            memory("uc2.json", EGRET_SERIES)
1324                .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1325        )
1326        .expect("egret series parses");
1327        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1328        assert!(module.source().is_some());
1329
1330        // The sniffed route agrees with the declared one.
1331        let module = parse(memory("uc2.json", EGRET_SERIES)).expect("sniffed egret parses");
1332        assert_value_type(&module, "powerio.TimeSeries<powerio.BalancedNetwork>");
1333    }
1334
1335    #[cfg(feature = "gridfm")]
1336    #[test]
1337    fn powerio_ir_uses_deserialize_not_parse() {
1338        use powerio_tx::{Bus, BusId, BusType};
1339        let network = powerio_tx::BalancedNetwork::in_memory(
1340            "stored",
1341            100.0,
1342            vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1343            vec![],
1344        );
1345        let original = powerio_core::PioModule::new(PioValue::BalancedNetwork(network));
1346        let emitted = serialize(&original, Destination::memory("case.pio.json").unwrap())
1347            .expect("module serializes");
1348        let EmittedOutput::Memory { artifacts } = emitted.into_output() else {
1349            unreachable!();
1350        };
1351        let module = deserialize(
1352            Source::from_memory("case.pio.json", artifacts[0].bytes().to_vec()).unwrap(),
1353        )
1354        .expect("module deserializes");
1355        assert_value_type(&module, "powerio.BalancedNetwork");
1356        assert!(module.source().is_some());
1357    }
1358
1359    #[cfg(feature = "gridfm")]
1360    #[test]
1361    fn a_gridfm_dataset_parses_to_a_scenario_set() {
1362        // Write a two scenario dataset with the matrix writer, then parse the
1363        // directory through the universal parse.
1364        let case = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/case9.m");
1365        let base = powerio_tx::parse(powerio_core::Source::open(case).unwrap())
1366            .expect("case9 parses")
1367            .into_value();
1368        let mut varied = base.clone();
1369        varied.loads_mut()[0].p += 5.0;
1370        let out = tempfile::tempdir().unwrap();
1371        let snapshots = [
1372            powerio_matrix::GridfmSnapshot::new(&base, 0),
1373            powerio_matrix::GridfmSnapshot::new(&varied, 1),
1374        ];
1375        powerio_matrix::emit_gridfm_batch(
1376            &snapshots,
1377            out.path(),
1378            &powerio_matrix::GridfmOptions::default(),
1379        )
1380        .expect("dataset writes");
1381
1382        let module =
1383            parse(powerio_core::Source::open(out.path()).unwrap()).expect("dataset parses");
1384        assert_value_type(&module, "powerio.ScenarioSet<powerio.BalancedNetwork>");
1385        assert!(module.source().is_some());
1386        let PioValue::ScenarioSet(set) = &module.value() else {
1387            unreachable!();
1388        };
1389        assert_eq!(set.len(), 2);
1390        assert!(set.get("0").is_some());
1391        assert!(set.get("1").is_some());
1392    }
1393
1394    #[test]
1395    fn an_unrecognized_directory_is_refused_with_the_hub_wording() {
1396        let dir = tempfile::tempdir().unwrap();
1397        std::fs::write(dir.path().join("notes.txt"), "not a case").unwrap();
1398        let error =
1399            parse(powerio_core::Source::open(dir.path()).unwrap()).expect_err("refused directory");
1400        assert!(error.to_string().contains("directory"), "{error}");
1401    }
1402
1403    #[test]
1404    fn a_scalar_egret_document_stays_a_balanced_network() {
1405        let scalar = EGRET_SERIES
1406            .replace(r#", "time_keys": ["t1", "t2"]"#, "")
1407            .replace(
1408                r#"{"data_type": "time_series", "values": [10.0, 20.0]}"#,
1409                "10.0",
1410            );
1411        let module = parse(
1412            memory("uc2.json", &scalar)
1413                .with_format(powerio_core::FormatId::new("egret-json").unwrap()),
1414        )
1415        .expect("scalar egret parses");
1416        assert_value_type(&module, "powerio.BalancedNetwork");
1417    }
1418}