Skip to main content

powerio_pkg/
package.rs

1//! The `.pio.json` root object.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6
7use powerio::{
8    BalancedNetwork, BusId, NORMALIZED_SOLVER_TABLES_PASS, NormalizedSolverTables,
9    SolverTableUnits, SourceFormat,
10};
11use powerio_dist::{DistSourceFormat, MulticonductorNetwork};
12
13use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
14use crate::lowering::{
15    LoweringRecord, MulticonductorToBalancedError, MulticonductorToBalancedOptions,
16    MulticonductorToBalancedReadiness, check_multiconductor_to_balanced_lowering,
17    lower_multiconductor_to_balanced,
18};
19use crate::model::{ModelKind, ModelPayload};
20use crate::operating::{
21    OperatingPointSeries, apply_operating_point_to_model, goc3_operating_points_from_str,
22    operating_point_update_paths,
23};
24use crate::provenance::{
25    Confidence, MappingKind, Origin, Producer, SourceDescriptor, SourceMapEntry, SourceRef,
26};
27use crate::summary::{ObjectSummary, ObjectTopology, ObjectUnits};
28use crate::validation::{ValidationPass, ValidationStatus, ValidationSummary};
29
30/// The canonical schema URL for this package version.
31pub const PIO_PACKAGE_SCHEMA_URL: &str = "https://powerio.dev/schema/pio-package/0.1";
32
33/// The package schema version (semver). Keep additive optional fields within
34/// the current version when older readers can ignore them; field moves bump the
35/// major (or ship a migration pass).
36pub const PIO_PACKAGE_SCHEMA_VERSION: &str = "0.1.0";
37
38fn default_schema_url() -> String {
39    PIO_PACKAGE_SCHEMA_URL.to_owned()
40}
41
42fn default_schema_version() -> String {
43    PIO_PACKAGE_SCHEMA_VERSION.to_owned()
44}
45
46/// Optional derived metadata: matrix statistics, solver table metadata, and
47/// cache keys.
48/// Empty by default; the scaffold never populates it.
49#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
50pub struct DerivedMetadata {
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub matrix_stats: Option<serde_json::Value>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub normalized_solver_tables: Option<NormalizedSolverTableMetadata>,
55    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
56    pub cache_keys: BTreeMap<String, String>,
57}
58
59impl DerivedMetadata {
60    fn is_empty(&self) -> bool {
61        self.matrix_stats.is_none()
62            && self.normalized_solver_tables.is_none()
63            && self.cache_keys.is_empty()
64    }
65}
66
67/// Compact package metadata for `Network::to_normalized_solver_tables`.
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct NormalizedSolverTableMetadata {
71    pub pass: String,
72    pub units: SolverTableUnits,
73    pub row_counts: NormalizedSolverTableRowCounts,
74    pub bus_ids: Vec<BusId>,
75    pub reference_bus_indices: Vec<usize>,
76    pub component_labels: Vec<usize>,
77    pub branch_from_arc_indices: Vec<usize>,
78    pub branch_to_arc_indices: Vec<usize>,
79    pub source_rows: NormalizedSolverTableSourceRows,
80}
81
82/// Row counts for every normalized solver table.
83#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[non_exhaustive]
85pub struct NormalizedSolverTableRowCounts {
86    pub buses: usize,
87    pub loads: usize,
88    pub shunts: usize,
89    pub branches: usize,
90    pub switches: usize,
91    pub arcs: usize,
92    pub generators: usize,
93    pub storage: usize,
94    pub hvdc: usize,
95}
96
97/// Source row provenance vectors for normalized solver tables.
98#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct NormalizedSolverTableSourceRows {
101    pub buses: Vec<Option<usize>>,
102    pub loads: Vec<Option<usize>>,
103    pub shunts: Vec<Option<usize>>,
104    pub branches: Vec<Option<usize>>,
105    pub switches: Vec<Option<usize>>,
106    pub generators: Vec<Option<usize>>,
107    pub storage: Vec<Option<usize>>,
108    pub hvdc: Vec<Option<usize>>,
109}
110
111impl From<&NormalizedSolverTables> for NormalizedSolverTableMetadata {
112    fn from(tables: &NormalizedSolverTables) -> Self {
113        Self {
114            pass: NORMALIZED_SOLVER_TABLES_PASS.to_owned(),
115            units: tables.units.clone(),
116            row_counts: NormalizedSolverTableRowCounts {
117                buses: tables.buses.len(),
118                loads: tables.loads.len(),
119                shunts: tables.shunts.len(),
120                branches: tables.branches.len(),
121                switches: tables.switches.len(),
122                arcs: tables.arcs.len(),
123                generators: tables.generators.len(),
124                storage: tables.storage.len(),
125                hvdc: tables.hvdc.len(),
126            },
127            bus_ids: tables.index.bus_ids.clone(),
128            reference_bus_indices: tables.index.reference_bus_indices.clone(),
129            component_labels: tables.index.component_labels.clone(),
130            branch_from_arc_indices: tables.index.branch_from_arc_indices.clone(),
131            branch_to_arc_indices: tables.index.branch_to_arc_indices.clone(),
132            source_rows: NormalizedSolverTableSourceRows {
133                buses: tables.index.bus_source_rows.clone(),
134                loads: tables.index.load_source_rows.clone(),
135                shunts: tables.index.shunt_source_rows.clone(),
136                branches: tables.index.branch_source_rows.clone(),
137                switches: tables.index.switch_source_rows.clone(),
138                generators: tables.index.generator_source_rows.clone(),
139                storage: tables.index.storage_source_rows.clone(),
140                hvdc: tables.index.hvdc_source_rows.clone(),
141            },
142        }
143    }
144}
145
146/// The compiler package: a versioned envelope around one IR payload plus the
147/// provenance, diagnostics, validation, and lowering history that make the
148/// artifact trustworthy. Serializes to `.pio.json`.
149///
150/// `model_kind` is stored explicitly and is authoritative; the payload is also
151/// self-describing (tagged by `kind`). [`NetworkPackage::kind_is_consistent`]
152/// asserts the two agree. Unknown future top-level fields are tolerated on read
153/// (ignored) so a newer producer's package still deserializes here.
154#[derive(Clone, Debug, Serialize, Deserialize)]
155#[non_exhaustive]
156pub struct NetworkPackage {
157    /// The schema URL identifying this package format.
158    #[serde(default = "default_schema_url")]
159    pub schema: String,
160    /// The package schema version (semver).
161    #[serde(default = "default_schema_version")]
162    pub schema_version: String,
163    pub producer: Producer,
164    /// Stable content id, e.g. `"sha256:..."`. The scaffold leaves it `None`.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub package_id: Option<String>,
167    /// RFC 3339 build timestamp. Left `None` by default for deterministic,
168    /// round-trip-stable output; set explicitly when a timestamp is wanted.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub created_at: Option<String>,
171    /// Explicit model kind. Authoritative; never inferred from field presence.
172    pub model_kind: ModelKind,
173    pub model: ModelPayload,
174    /// Replayable operating states over the static payload. The package
175    /// constructors and setters omit empty series for static single state cases.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub operating_points: Option<OperatingPointSeries>,
178    pub origin: Origin,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub sources: Vec<SourceDescriptor>,
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub source_maps: Vec<SourceMapEntry>,
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub diagnostics: Vec<StructuredDiagnostic>,
185    pub validation: ValidationSummary,
186    #[serde(default)]
187    pub summary: ObjectSummary,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub lowering_history: Vec<LoweringRecord>,
190    #[serde(default, skip_serializing_if = "DerivedMetadata::is_empty")]
191    pub derived: DerivedMetadata,
192}
193
194impl NetworkPackage {
195    /// Wrap a balanced network. Origin is inferred from its source format:
196    /// `InMemory` / `Derived` (normalized) / `File` (a parsed text format,
197    /// recording whether source was retained; the path is not captured here).
198    /// GOC3 sources also lift their time series into `operating_points`.
199    pub fn from_balanced(net: BalancedNetwork) -> Self {
200        let origin = balanced_origin(&net);
201        let summary = balanced_summary(&net);
202        let sources = balanced_sources(&net);
203        let source_id = sources.first().map(|s| s.id.clone());
204        let source_maps = balanced_source_maps(&net, source_id.as_deref());
205        let mut diagnostics = Vec::new();
206        let operating_points = if net.source_format == SourceFormat::Goc3Json {
207            match net
208                .source
209                .as_deref()
210                .map(|source| goc3_operating_points_from_str(source))
211            {
212                Some(Ok(series)) => series,
213                Some(Err(err)) => {
214                    diagnostics.push(StructuredDiagnostic::new(
215                        "READ.GOC3.OPERATING_POINTS_DROPPED",
216                        DiagnosticSeverity::Warning,
217                        DiagnosticStage::Read,
218                        format!(
219                            "time series could not be lifted into operating points; \
220                             the package is static only: {err}"
221                        ),
222                    ));
223                    None
224                }
225                None => None,
226            }
227        } else {
228            None
229        };
230        let validation = ValidationSummary::from_diagnostics(&diagnostics);
231        Self {
232            schema: default_schema_url(),
233            schema_version: default_schema_version(),
234            producer: Producer::powerio(),
235            package_id: None,
236            created_at: None,
237            model_kind: ModelKind::Balanced,
238            model: ModelPayload::balanced(net),
239            operating_points,
240            origin,
241            sources,
242            source_maps,
243            diagnostics,
244            validation,
245            summary,
246            lowering_history: Vec::new(),
247            derived: DerivedMetadata::default(),
248        }
249    }
250
251    /// Wrap a multiconductor network. Parse `warnings` are lifted into structured
252    /// diagnostics, and `defaulted` fields are lifted into source maps with
253    /// `mapping_kind = defaulted`, so the package surfaces that provenance even
254    /// though those parser-side fields are not part of the IR payload.
255    pub fn from_multiconductor(net: MulticonductorNetwork) -> Self {
256        let summary = multiconductor_summary(&net);
257        let sources = multiconductor_sources(&net);
258        let source_id = sources.first().map(|s| s.id.clone());
259        let source_maps = multiconductor_source_maps(&net, source_id.as_deref());
260        let origin = multiconductor_origin(&net);
261
262        let diagnostics: Vec<StructuredDiagnostic> = net
263            .warnings
264            .iter()
265            .map(|w| {
266                StructuredDiagnostic::new(
267                    "READ.DIST.PARSE_WARNING",
268                    DiagnosticSeverity::Warning,
269                    DiagnosticStage::Read,
270                    w.clone(),
271                )
272            })
273            .collect();
274        let validation = ValidationSummary::from_diagnostics(&diagnostics);
275
276        Self {
277            schema: default_schema_url(),
278            schema_version: default_schema_version(),
279            producer: Producer::powerio(),
280            package_id: None,
281            created_at: None,
282            model_kind: ModelKind::Multiconductor,
283            model: ModelPayload::multiconductor(net),
284            operating_points: None,
285            origin,
286            sources,
287            source_maps,
288            diagnostics,
289            validation,
290            summary,
291            lowering_history: Vec::new(),
292            derived: DerivedMetadata::default(),
293        }
294    }
295
296    /// The explicit model kind.
297    pub fn model_kind(&self) -> ModelKind {
298        self.model_kind
299    }
300
301    /// Whether the explicit `model_kind` agrees with the payload variant. A
302    /// reader should reject a package where this is false.
303    pub fn kind_is_consistent(&self) -> bool {
304        self.model_kind == self.model.kind()
305    }
306
307    /// The balanced payload, if this package carries one.
308    pub fn as_balanced(&self) -> Option<&BalancedNetwork> {
309        self.model.as_balanced()
310    }
311
312    /// The multiconductor payload, if this package carries one.
313    pub fn as_multiconductor(&self) -> Option<&MulticonductorNetwork> {
314        self.model.as_multiconductor()
315    }
316
317    /// Replayable operating states over the static payload, when present.
318    #[must_use]
319    pub fn operating_points(&self) -> Option<&OperatingPointSeries> {
320        self.operating_points.as_ref()
321    }
322
323    /// Attach a format neutral operating point series to this package.
324    #[must_use]
325    pub fn with_operating_points(mut self, operating_points: OperatingPointSeries) -> Self {
326        self.set_operating_points(operating_points);
327        self
328    }
329
330    /// Attach or replace operating points in place. Empty series are omitted.
331    pub fn set_operating_points(&mut self, operating_points: OperatingPointSeries) {
332        self.operating_points = (!operating_points.is_empty()).then_some(operating_points);
333    }
334
335    /// Remove operating points from this package.
336    pub fn clear_operating_points(&mut self) {
337        self.operating_points = None;
338    }
339
340    /// Materialize one operating point into a static package.
341    ///
342    /// The returned package has the same metadata and model kind, with its
343    /// payload updated for `index`, `operating_points` cleared, and sane
344    /// validation recomputed for the updated payload.
345    pub fn materialize_operating_point(&self, index: usize) -> serde_json::Result<Self> {
346        let series = self.operating_points.as_ref().ok_or_else(|| {
347            <serde_json::Error as serde::de::Error>::custom("package has no operating points")
348        })?;
349        let point = series.unique_point(index)?.ok_or_else(|| {
350            <serde_json::Error as serde::de::Error>::custom(format!(
351                "package has no operating point {index}"
352            ))
353        })?;
354        let updated_paths = operating_point_update_paths(&self.model, point);
355        let had_normalized_solver_tables = self.derived.normalized_solver_tables.is_some();
356        let options = materialize_operating_point_options(index);
357        // Built field by field rather than cloned: cloning would deep copy the
358        // whole payload only to overwrite it, and a future envelope field must
359        // make an explicit carry-or-clear decision here instead of silently
360        // riding along stale.
361        let mut package = Self {
362            schema: self.schema.clone(),
363            schema_version: self.schema_version.clone(),
364            producer: self.producer.clone(),
365            // A derived package is new content: it records the parent's id in
366            // its origin and never inherits it as its own (as in
367            // `lower_multiconductor_to_balanced`).
368            package_id: None,
369            created_at: self.created_at.clone(),
370            model_kind: self.model_kind,
371            model: apply_operating_point_to_model(&self.model, point)?,
372            operating_points: None,
373            origin: Origin::Derived {
374                parent_package_id: self.package_id.clone(),
375                pass: "materialize-operating-point".to_owned(),
376                options: options.clone(),
377            },
378            sources: self.sources.clone(),
379            source_maps: self
380                .source_maps
381                .iter()
382                .filter(|entry| !updated_paths.contains(entry.element_path.as_str()))
383                .cloned()
384                .collect(),
385            diagnostics: self
386                .diagnostics
387                .iter()
388                .filter(|diagnostic| {
389                    diagnostic
390                        .element_path
391                        .as_deref()
392                        .is_none_or(|path| !updated_paths.contains(path))
393                })
394                .cloned()
395                .collect(),
396            // Replaced by run_sane_validation below.
397            validation: self.validation.clone(),
398            summary: self.summary.clone(),
399            lowering_history: self.lowering_history.clone(),
400            // Derived products are stale against the updated payload; solver
401            // table metadata is rebuilt below when the parent carried it.
402            derived: DerivedMetadata::default(),
403        };
404        let mut record = LoweringRecord::new(
405            "materialize-operating-point",
406            self.model_kind,
407            self.model_kind,
408        );
409        record.options = options;
410        package.run_sane_validation();
411        record.validation_status = package.validation.status;
412        package.push_lowering(record);
413        if had_normalized_solver_tables {
414            package
415                .attach_normalized_solver_table_metadata()
416                .map_err(|err| {
417                    <serde_json::Error as serde::de::Error>::custom(format!(
418                        "failed to recompute normalized solver table metadata: {err}"
419                    ))
420                })?;
421        }
422        Ok(package)
423    }
424
425    /// Materialize one operating point and return the balanced payload if this
426    /// is a balanced package.
427    pub fn materialize_balanced_operating_point(
428        &self,
429        index: usize,
430    ) -> serde_json::Result<Option<BalancedNetwork>> {
431        Ok(self
432            .materialize_operating_point(index)?
433            .model
434            .as_balanced()
435            .cloned())
436    }
437
438    /// Materialize one operating point and return the multiconductor payload if
439    /// this is a multiconductor package.
440    pub fn materialize_multiconductor_operating_point(
441        &self,
442        index: usize,
443    ) -> serde_json::Result<Option<MulticonductorNetwork>> {
444        Ok(self
445            .materialize_operating_point(index)?
446            .model
447            .as_multiconductor()
448            .cloned())
449    }
450
451    /// Serialize to compact `.pio.json`.
452    pub fn to_json(&self) -> serde_json::Result<String> {
453        serde_json::to_string(self)
454    }
455
456    /// Serialize to pretty `.pio.json`.
457    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
458        serde_json::to_string_pretty(self)
459    }
460
461    /// Deserialize from `.pio.json`.
462    pub fn from_json(text: &str) -> serde_json::Result<Self> {
463        let pkg: Self = serde_json::from_str(text)?;
464        if !Self::supports_schema_version(&pkg.schema_version) {
465            return Err(<serde_json::Error as serde::de::Error>::custom(format!(
466                "unsupported .pio.json schema_version {}; this reader supports major version {}",
467                pkg.schema_version,
468                supported_schema_major()
469            )));
470        }
471        if !pkg.kind_is_consistent() {
472            return Err(<serde_json::Error as serde::de::Error>::custom(
473                "model_kind does not match model.kind",
474            ));
475        }
476        Ok(pkg)
477    }
478
479    /// Whether this reader accepts the envelope schema version.
480    ///
481    /// The `.pio.json` compatibility contract is envelope scoped: unknown
482    /// future top-level fields are ignored, additive same major versions load,
483    /// and a different major version is rejected before payload use.
484    pub fn supports_schema_version(version: &str) -> bool {
485        schema_major(version).is_some_and(|major| major == supported_schema_major())
486    }
487
488    #[must_use]
489    pub fn with_origin(mut self, origin: Origin) -> Self {
490        self.origin = origin;
491        self
492    }
493
494    #[must_use]
495    pub fn with_package_id(mut self, id: impl Into<String>) -> Self {
496        self.package_id = Some(id.into());
497        self
498    }
499
500    #[must_use]
501    pub fn with_created_at(mut self, created_at: impl Into<String>) -> Self {
502        self.created_at = Some(created_at.into());
503        self
504    }
505
506    #[must_use]
507    pub fn with_sources(mut self, sources: Vec<SourceDescriptor>) -> Self {
508        self.sources = sources;
509        self
510    }
511
512    #[must_use]
513    pub fn with_source_maps(mut self, source_maps: Vec<SourceMapEntry>) -> Self {
514        self.source_maps = source_maps;
515        self
516    }
517
518    /// Append a lowering record to the history.
519    pub fn push_lowering(&mut self, record: LoweringRecord) {
520        self.lowering_history.push(record);
521    }
522
523    /// Attach compact metadata for the normalized dense solver table lowering.
524    ///
525    /// Returns `Ok(false)` for non-balanced packages. The full table rows stay
526    /// outside the package payload; this records the pass name, row counts,
527    /// units, dense identities, and source row provenance a compiler cache needs
528    /// to validate external table artifacts.
529    pub fn attach_normalized_solver_table_metadata(
530        &mut self,
531    ) -> std::result::Result<bool, powerio::Error> {
532        let Some(net) = self.as_balanced() else {
533            return Ok(false);
534        };
535        let tables = net.to_normalized_solver_tables()?;
536        self.derived.normalized_solver_tables = Some(NormalizedSolverTableMetadata::from(&tables));
537        Ok(true)
538    }
539
540    /// Return a package with normalized solver table metadata attached.
541    pub fn with_normalized_solver_table_metadata(
542        mut self,
543    ) -> std::result::Result<Self, powerio::Error> {
544        self.attach_normalized_solver_table_metadata()?;
545        Ok(self)
546    }
547
548    /// Check whether this package's multiconductor payload is ready for the
549    /// explicit multiconductor to balanced lowering pass.
550    #[must_use]
551    pub fn check_multiconductor_to_balanced_lowering(
552        &self,
553    ) -> Option<MulticonductorToBalancedReadiness> {
554        self.as_multiconductor().map(|net| {
555            check_multiconductor_to_balanced_lowering(
556                net,
557                MulticonductorToBalancedOptions::default(),
558            )
559        })
560    }
561
562    /// Explicitly lower a multiconductor package to a derived balanced package.
563    ///
564    /// This method only accepts packages whose payload is
565    /// [`ModelKind::Multiconductor`]. It does not mutate the input package.
566    pub fn lower_multiconductor_to_balanced(
567        &self,
568        options: MulticonductorToBalancedOptions,
569    ) -> Result<Self, MulticonductorToBalancedError> {
570        let Some(net) = self.as_multiconductor() else {
571            let diagnostic = StructuredDiagnostic::new(
572                "LOWER.MULTI_TO_BALANCED.WRONG_MODEL_KIND",
573                DiagnosticSeverity::Error,
574                DiagnosticStage::Lower,
575                format!(
576                    "multiconductor to balanced lowering requires a multiconductor package, got {:?}",
577                    self.model_kind
578                ),
579            );
580            return Err(MulticonductorToBalancedError::new(
581                options,
582                vec![diagnostic],
583            ));
584        };
585
586        let lowered = lower_multiconductor_to_balanced(net, options)?;
587        let mut record = lowered.record;
588        let mut output = NetworkPackage::from_balanced(lowered.network);
589        output.origin = Origin::Derived {
590            parent_package_id: self.package_id.clone(),
591            pass: "multiconductor-to-balanced".to_owned(),
592            options: record.options.clone(),
593        };
594        output.sources = derived_sources(self);
595        let source_id = output.sources.first().map(|source| source.id.as_str());
596        output.source_maps = match output.as_balanced() {
597            Some(balanced) => lowered_balanced_source_maps(net, balanced, source_id),
598            None => Vec::new(),
599        };
600        output.diagnostics.clone_from(&record.diagnostics);
601        output.lowering_history.clone_from(&self.lowering_history);
602        output.run_sane_validation();
603        record.validation_status = output.validation.status;
604        output.push_lowering(record);
605        Ok(output)
606    }
607
608    /// Run the package semantic validation profile and record its findings.
609    ///
610    /// This pass leaves the payload untouched: it reports structural and
611    /// semantic issues, but never repairs or rewrites the model. It does rewrite
612    /// the package's own `diagnostics` and `validation`, so it needs `&mut self`.
613    pub fn run_sane_validation(&mut self) {
614        self.diagnostics
615            .retain(|d| !is_sane_validation_code(d.code.as_str()));
616
617        let (mut diagnostics, passes) = match &self.model {
618            ModelPayload::Balanced { balanced_network } => sane_validate_balanced(balanced_network),
619            ModelPayload::Multiconductor {
620                multiconductor_network,
621            } => sane_validate_multiconductor(multiconductor_network),
622        };
623
624        attach_source_refs(&mut diagnostics, &self.source_maps);
625        self.diagnostics.extend(diagnostics);
626        self.validation =
627            ValidationSummary::from_diagnostics(&self.diagnostics).with_passes(passes);
628    }
629}
630
631fn materialize_operating_point_options(index: usize) -> serde_json::Map<String, serde_json::Value> {
632    let mut options = serde_json::Map::new();
633    options.insert("index".to_owned(), serde_json::json!(index));
634    options
635}
636
637fn schema_major(version: &str) -> Option<u64> {
638    // Accept a semver core `MAJOR.MINOR.PATCH` with an optional prerelease
639    // (`-...`) or build (`+...`) tag: same-major additive versions load, so a
640    // forward-compatible writer that stamps e.g. `0.2.0-rc.1` is not rejected.
641    let (core, suffix) = match version.split_once('-') {
642        Some((core, rest)) => match rest.split_once('+') {
643            Some((pre, build)) => (core, Some((Some(pre), Some(build)))),
644            None => (core, Some((Some(rest), None))),
645        },
646        None => match version.split_once('+') {
647            Some((core, build)) => (core, Some((None, Some(build)))),
648            None => (version, None),
649        },
650    };
651    if let Some((pre, build)) = suffix {
652        if pre.is_some_and(|s| !valid_semver_suffix(s))
653            || build.is_some_and(|s| !valid_semver_suffix(s))
654        {
655            return None;
656        }
657    }
658    let mut parts = core.split('.');
659    let major = parts.next()?;
660    let minor = parts.next()?;
661    let patch = parts.next()?;
662    if parts.next().is_some() {
663        return None;
664    }
665    let major = parse_semver_number(major)?;
666    parse_semver_number(minor)?;
667    parse_semver_number(patch)?;
668    Some(major)
669}
670
671fn parse_semver_number(s: &str) -> Option<u64> {
672    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0'))
673    {
674        return None;
675    }
676    s.parse().ok()
677}
678
679fn valid_semver_suffix(s: &str) -> bool {
680    !s.is_empty()
681        && s.split('.').all(|part| {
682            !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
683        })
684}
685
686fn supported_schema_major() -> u64 {
687    schema_major(PIO_PACKAGE_SCHEMA_VERSION).expect("package schema version has a major number")
688}
689
690const SANE_VALIDATION_CODES: [&str; 6] = [
691    "VALIDATE.BALANCED.STRUCTURE",
692    "VALIDATE.BALANCED.VALUE_DOMAIN",
693    "VALIDATE.MULTI.STRUCTURE",
694    "VALIDATE.MULTI.TERMINAL_MAP",
695    "VALIDATE.MULTI.UNTYPED_OBJECT",
696    "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
697];
698
699fn is_sane_validation_code(code: &str) -> bool {
700    SANE_VALIDATION_CODES.contains(&code)
701}
702
703fn validation_status(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
704    diagnostics
705        .iter()
706        .map(|d| match d.severity {
707            DiagnosticSeverity::Debug => ValidationStatus::Ok,
708            DiagnosticSeverity::Info => ValidationStatus::Info,
709            DiagnosticSeverity::Warning => ValidationStatus::Warning,
710            DiagnosticSeverity::Error => ValidationStatus::Error,
711            DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
712        })
713        .max()
714        .unwrap_or(ValidationStatus::Ok)
715}
716
717fn sane_validate_balanced(
718    net: &BalancedNetwork,
719) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
720    let mut structure = Vec::new();
721    if let Err(err) = net.validate() {
722        structure.push(StructuredDiagnostic::new(
723            "VALIDATE.BALANCED.STRUCTURE",
724            DiagnosticSeverity::Error,
725            DiagnosticStage::Validate,
726            err.to_string(),
727        ));
728    }
729
730    let bus_index: HashMap<usize, usize> = net
731        .buses
732        .iter()
733        .enumerate()
734        .map(|(idx, b)| (b.id.0, idx))
735        .collect();
736    let mut value_domain = Vec::new();
737    for finding in net.validate_values() {
738        let element_path =
739            balanced_value_finding_path(net, &bus_index, &finding).unwrap_or_else(|| {
740                format!(
741                    "/model/balanced_network/{}#{}",
742                    finding.element.replace(' ', "_"),
743                    finding.field
744                )
745            });
746        let mut d = StructuredDiagnostic::new(
747            "VALIDATE.BALANCED.VALUE_DOMAIN",
748            DiagnosticSeverity::Warning,
749            DiagnosticStage::Validate,
750            format!(
751                "{} field `{}` is outside its value domain; suggested value is {}",
752                finding.element, finding.field, finding.new
753            ),
754        )
755        .with_element_path(element_path)
756        .with_suggested_action("Run the explicit repair pass if these defaults are desired.");
757        d.details
758            .insert("element".to_owned(), serde_json::json!(finding.element));
759        d.details
760            .insert("field".to_owned(), serde_json::json!(finding.field));
761        d.details
762            .insert("old".to_owned(), serde_json::json!(finding.old));
763        d.details
764            .insert("new".to_owned(), serde_json::json!(finding.new));
765        d.details
766            .insert("reason".to_owned(), serde_json::json!(finding.reason));
767        value_domain.push(d);
768    }
769
770    let passes = vec![
771        ValidationPass::new("balanced.structure", validation_status(&structure)),
772        ValidationPass::new("balanced.value_domain", validation_status(&value_domain)),
773    ];
774    structure.extend(value_domain);
775    (structure, passes)
776}
777
778fn attach_source_refs(diagnostics: &mut [StructuredDiagnostic], source_maps: &[SourceMapEntry]) {
779    // Index by element path once: `source_maps` holds a row per field per
780    // element, so a per-diagnostic linear scan is quadratic. First entry wins,
781    // matching the previous `iter().find` order.
782    let mut by_path: HashMap<&str, &SourceRef> = HashMap::with_capacity(source_maps.len());
783    for map in source_maps {
784        by_path
785            .entry(map.element_path.as_str())
786            .or_insert(&map.source_ref);
787    }
788    for diagnostic in diagnostics {
789        if diagnostic.source_ref.is_some() {
790            continue;
791        }
792        let Some(path) = diagnostic.element_path.as_deref() else {
793            continue;
794        };
795        if let Some(source_ref) = by_path.get(path) {
796            diagnostic.source_ref = Some((*source_ref).clone());
797        }
798    }
799}
800
801fn balanced_value_finding_path(
802    net: &BalancedNetwork,
803    bus_index: &HashMap<usize, usize>,
804    finding: &powerio::Diagnostic,
805) -> Option<String> {
806    if let Some(id) = finding
807        .element
808        .strip_prefix("bus ")
809        .and_then(|s| s.parse::<usize>().ok())
810    {
811        let idx = *bus_index.get(&id)?;
812        return Some(format!(
813            "/model/balanced_network/buses/{idx}/{}",
814            finding.field
815        ));
816    }
817
818    if let Some(id) = finding
819        .element
820        .strip_prefix("generator at bus ")
821        .and_then(|s| s.parse::<usize>().ok())
822    {
823        // When several units at a bus share the same out-of-domain value the
824        // finding cannot be pinned to one array index, so skip the precise path
825        // rather than misattribute it (see the ambiguity test).
826        let mut matches = net
827            .generators
828            .iter()
829            .enumerate()
830            .filter(|(_, g)| {
831                g.bus.0 == id
832                    && generator_field(g, finding.field)
833                        .is_some_and(|v| v.to_bits() == finding.old.to_bits())
834            })
835            .map(|(idx, _)| idx);
836        let idx = matches.next()?;
837        if matches.next().is_some() {
838            return None;
839        }
840        return Some(format!(
841            "/model/balanced_network/generators/{idx}/{}",
842            finding.field
843        ));
844    }
845
846    None
847}
848
849fn generator_field(generator: &powerio::Generator, field: &str) -> Option<f64> {
850    Some(match field {
851        "mbase" => generator.mbase,
852        "vg" => generator.vg,
853        _ => return None,
854    })
855}
856
857fn sane_validate_multiconductor(
858    net: &MulticonductorNetwork,
859) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
860    let mut structure = Vec::new();
861    let mut terminal_maps = Vec::new();
862    let mut untyped = Vec::new();
863    let mut sources = Vec::new();
864
865    let (bus_ids, bus_terminals) = multiconductor_bus_index(net, &mut structure);
866
867    validate_multiconductor_lines(
868        net,
869        &bus_ids,
870        &bus_terminals,
871        &mut structure,
872        &mut terminal_maps,
873    );
874    validate_multiconductor_switches(
875        net,
876        &bus_ids,
877        &bus_terminals,
878        &mut structure,
879        &mut terminal_maps,
880    );
881    validate_multiconductor_transformers(
882        net,
883        &bus_ids,
884        &bus_terminals,
885        &mut structure,
886        &mut terminal_maps,
887    );
888    validate_multiconductor_injections(
889        net,
890        &bus_ids,
891        &bus_terminals,
892        &mut structure,
893        &mut terminal_maps,
894    );
895
896    for (i, obj) in net.untyped.iter().enumerate() {
897        untyped.push(
898            StructuredDiagnostic::new(
899                "VALIDATE.MULTI.UNTYPED_OBJECT",
900                DiagnosticSeverity::Warning,
901                DiagnosticStage::Validate,
902                format!(
903                    "{} {} is preserved as an untyped object",
904                    obj.class, obj.name
905                ),
906            )
907            .with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
908        );
909    }
910
911    if net.sources.is_empty() {
912        sources.push(StructuredDiagnostic::new(
913            "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
914            DiagnosticSeverity::Warning,
915            DiagnosticStage::Validate,
916            "multiconductor package has no voltage source",
917        ));
918    }
919
920    let passes = vec![
921        ValidationPass::new("multiconductor.structure", validation_status(&structure)),
922        ValidationPass::new(
923            "multiconductor.terminal_map",
924            validation_status(&terminal_maps),
925        ),
926        ValidationPass::new("multiconductor.untyped_object", validation_status(&untyped)),
927        ValidationPass::new("multiconductor.voltage_source", validation_status(&sources)),
928    ];
929
930    let mut diagnostics = structure;
931    diagnostics.extend(terminal_maps);
932    diagnostics.extend(untyped);
933    diagnostics.extend(sources);
934    (diagnostics, passes)
935}
936
937fn validate_multiconductor_lines(
938    net: &MulticonductorNetwork,
939    bus_ids: &BTreeSet<String>,
940    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
941    structure: &mut Vec<StructuredDiagnostic>,
942    terminal_maps: &mut Vec<StructuredDiagnostic>,
943) {
944    for (i, line) in net.lines.iter().enumerate() {
945        check_bus_ref(
946            &line.bus_from,
947            &format!("line {} from bus", line.name),
948            &format!("/model/multiconductor_network/lines/{i}/bus_from"),
949            bus_ids,
950            structure,
951        );
952        check_bus_ref(
953            &line.bus_to,
954            &format!("line {} to bus", line.name),
955            &format!("/model/multiconductor_network/lines/{i}/bus_to"),
956            bus_ids,
957            structure,
958        );
959        if !net
960            .linecodes
961            .iter()
962            .any(|c| c.name.eq_ignore_ascii_case(&line.linecode))
963        {
964            structure.push(
965                StructuredDiagnostic::new(
966                    "VALIDATE.MULTI.STRUCTURE",
967                    DiagnosticSeverity::Error,
968                    DiagnosticStage::Validate,
969                    format!(
970                        "line {} references unknown linecode `{}`",
971                        line.name, line.linecode
972                    ),
973                )
974                .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
975            );
976        }
977        check_terminal_map(
978            &line.bus_from,
979            &line.terminal_map_from,
980            &format!("line {} from terminals", line.name),
981            &format!("/model/multiconductor_network/lines/{i}/terminal_map_from"),
982            bus_terminals,
983            terminal_maps,
984        );
985        check_terminal_map(
986            &line.bus_to,
987            &line.terminal_map_to,
988            &format!("line {} to terminals", line.name),
989            &format!("/model/multiconductor_network/lines/{i}/terminal_map_to"),
990            bus_terminals,
991            terminal_maps,
992        );
993    }
994}
995
996fn validate_multiconductor_switches(
997    net: &MulticonductorNetwork,
998    bus_ids: &BTreeSet<String>,
999    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1000    structure: &mut Vec<StructuredDiagnostic>,
1001    terminal_maps: &mut Vec<StructuredDiagnostic>,
1002) {
1003    for (i, sw) in net.switches.iter().enumerate() {
1004        check_bus_ref(
1005            &sw.bus_from,
1006            &format!("switch {} from bus", sw.name),
1007            &format!("/model/multiconductor_network/switches/{i}/bus_from"),
1008            bus_ids,
1009            structure,
1010        );
1011        check_bus_ref(
1012            &sw.bus_to,
1013            &format!("switch {} to bus", sw.name),
1014            &format!("/model/multiconductor_network/switches/{i}/bus_to"),
1015            bus_ids,
1016            structure,
1017        );
1018        check_terminal_map(
1019            &sw.bus_from,
1020            &sw.terminal_map_from,
1021            &format!("switch {} from terminals", sw.name),
1022            &format!("/model/multiconductor_network/switches/{i}/terminal_map_from"),
1023            bus_terminals,
1024            terminal_maps,
1025        );
1026        check_terminal_map(
1027            &sw.bus_to,
1028            &sw.terminal_map_to,
1029            &format!("switch {} to terminals", sw.name),
1030            &format!("/model/multiconductor_network/switches/{i}/terminal_map_to"),
1031            bus_terminals,
1032            terminal_maps,
1033        );
1034    }
1035}
1036
1037fn validate_multiconductor_transformers(
1038    net: &MulticonductorNetwork,
1039    bus_ids: &BTreeSet<String>,
1040    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1041    structure: &mut Vec<StructuredDiagnostic>,
1042    terminal_maps: &mut Vec<StructuredDiagnostic>,
1043) {
1044    for (i, tx) in net.transformers.iter().enumerate() {
1045        for (j, winding) in tx.windings.iter().enumerate() {
1046            check_bus_ref(
1047                &winding.bus,
1048                &format!("transformer {} winding {j} bus", tx.name),
1049                &format!("/model/multiconductor_network/transformers/{i}/windings/{j}/bus"),
1050                bus_ids,
1051                structure,
1052            );
1053            check_terminal_map(
1054                &winding.bus,
1055                &winding.terminal_map,
1056                &format!("transformer {} winding {j} terminals", tx.name),
1057                &format!(
1058                    "/model/multiconductor_network/transformers/{i}/windings/{j}/terminal_map"
1059                ),
1060                bus_terminals,
1061                terminal_maps,
1062            );
1063        }
1064    }
1065}
1066
1067fn validate_multiconductor_injections(
1068    net: &MulticonductorNetwork,
1069    bus_ids: &BTreeSet<String>,
1070    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1071    structure: &mut Vec<StructuredDiagnostic>,
1072    terminal_maps: &mut Vec<StructuredDiagnostic>,
1073) {
1074    let mut ctx = MultiValidationContext {
1075        bus_ids,
1076        bus_terminals,
1077        structure,
1078        terminal_maps,
1079    };
1080    for (i, load) in net.loads.iter().enumerate() {
1081        check_one_bus_element(
1082            &load.bus,
1083            &load.terminal_map,
1084            &format!("load {}", load.name),
1085            &format!("/model/multiconductor_network/loads/{i}"),
1086            &mut ctx,
1087        );
1088    }
1089    for (i, generator) in net.generators.iter().enumerate() {
1090        check_one_bus_element(
1091            &generator.bus,
1092            &generator.terminal_map,
1093            &format!("generator {}", generator.name),
1094            &format!("/model/multiconductor_network/generators/{i}"),
1095            &mut ctx,
1096        );
1097    }
1098    for (i, shunt) in net.shunts.iter().enumerate() {
1099        check_one_bus_element(
1100            &shunt.bus,
1101            &shunt.terminal_map,
1102            &format!("shunt {}", shunt.name),
1103            &format!("/model/multiconductor_network/shunts/{i}"),
1104            &mut ctx,
1105        );
1106    }
1107    for (i, source) in net.sources.iter().enumerate() {
1108        check_one_bus_element(
1109            &source.bus,
1110            &source.terminal_map,
1111            &format!("voltage source {}", source.name),
1112            &format!("/model/multiconductor_network/sources/{i}"),
1113            &mut ctx,
1114        );
1115    }
1116}
1117
1118struct MultiValidationContext<'a> {
1119    bus_ids: &'a BTreeSet<String>,
1120    bus_terminals: &'a BTreeMap<String, BTreeSet<String>>,
1121    structure: &'a mut Vec<StructuredDiagnostic>,
1122    terminal_maps: &'a mut Vec<StructuredDiagnostic>,
1123}
1124
1125fn check_one_bus_element(
1126    bus: &str,
1127    terminal_map: &[String],
1128    label: &str,
1129    path: &str,
1130    ctx: &mut MultiValidationContext<'_>,
1131) {
1132    check_bus_ref(
1133        bus,
1134        &format!("{label} bus"),
1135        &format!("{path}/bus"),
1136        ctx.bus_ids,
1137        ctx.structure,
1138    );
1139    check_terminal_map(
1140        bus,
1141        terminal_map,
1142        &format!("{label} terminals"),
1143        &format!("{path}/terminal_map"),
1144        ctx.bus_terminals,
1145        ctx.terminal_maps,
1146    );
1147}
1148
1149fn multiconductor_bus_index(
1150    net: &MulticonductorNetwork,
1151    diagnostics: &mut Vec<StructuredDiagnostic>,
1152) -> (BTreeSet<String>, BTreeMap<String, BTreeSet<String>>) {
1153    let mut ids = BTreeSet::new();
1154    let mut terminals = BTreeMap::new();
1155    let mut first_seen = BTreeMap::<String, String>::new();
1156    for (i, bus) in net.buses.iter().enumerate() {
1157        let key = bus.id.to_ascii_lowercase();
1158        if let Some(first) = first_seen.insert(key.clone(), bus.id.clone()) {
1159            diagnostics.push(
1160                StructuredDiagnostic::new(
1161                    "VALIDATE.MULTI.STRUCTURE",
1162                    DiagnosticSeverity::Error,
1163                    DiagnosticStage::Validate,
1164                    format!("duplicate bus id `{}` conflicts with `{first}`", bus.id),
1165                )
1166                .with_element_path(format!("/model/multiconductor_network/buses/{i}/id")),
1167            );
1168        }
1169        ids.insert(key.clone());
1170        terminals.insert(key, bus.terminals.iter().cloned().collect());
1171    }
1172    (ids, terminals)
1173}
1174
1175fn check_bus_ref(
1176    bus: &str,
1177    what: &str,
1178    path: &str,
1179    bus_ids: &BTreeSet<String>,
1180    diagnostics: &mut Vec<StructuredDiagnostic>,
1181) {
1182    if !bus_ids.contains(&bus.to_ascii_lowercase()) {
1183        diagnostics.push(
1184            StructuredDiagnostic::new(
1185                "VALIDATE.MULTI.STRUCTURE",
1186                DiagnosticSeverity::Error,
1187                DiagnosticStage::Validate,
1188                format!("{what} references unknown bus `{bus}`"),
1189            )
1190            .with_element_path(path),
1191        );
1192    }
1193}
1194
1195fn check_terminal_map(
1196    bus: &str,
1197    terminal_map: &[String],
1198    what: &str,
1199    path: &str,
1200    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1201    diagnostics: &mut Vec<StructuredDiagnostic>,
1202) {
1203    if terminal_map.is_empty() {
1204        diagnostics.push(
1205            StructuredDiagnostic::new(
1206                "VALIDATE.MULTI.TERMINAL_MAP",
1207                DiagnosticSeverity::Error,
1208                DiagnosticStage::Validate,
1209                format!("{what} has an empty terminal map"),
1210            )
1211            .with_element_path(path),
1212        );
1213        return;
1214    }
1215
1216    let Some(known) = bus_terminals.get(&bus.to_ascii_lowercase()) else {
1217        return;
1218    };
1219    for terminal in terminal_map {
1220        if !known.contains(terminal) {
1221            diagnostics.push(
1222                StructuredDiagnostic::new(
1223                    "VALIDATE.MULTI.TERMINAL_MAP",
1224                    DiagnosticSeverity::Error,
1225                    DiagnosticStage::Validate,
1226                    format!("{what} references unknown terminal `{terminal}` on bus `{bus}`"),
1227                )
1228                .with_element_path(path),
1229            );
1230        }
1231    }
1232}
1233
1234/// Canonical format name for a balanced source format.
1235fn balanced_origin(net: &BalancedNetwork) -> Origin {
1236    match net.source_format {
1237        SourceFormat::InMemory => Origin::InMemory,
1238        SourceFormat::Normalized => Origin::Derived {
1239            parent_package_id: None,
1240            pass: "normalize-balanced".to_owned(),
1241            options: serde_json::Map::new(),
1242        },
1243        SourceFormat::Gridfm | SourceFormat::PypsaCsv => Origin::Folder {
1244            path: String::new(),
1245            format: net.source_format.name().to_owned(),
1246            file_hashes: BTreeMap::new(),
1247        },
1248        SourceFormat::PowerWorldBinary => Origin::BinaryFile {
1249            path: String::new(),
1250            format: net.source_format.name().to_owned(),
1251            hash: None,
1252            decoded_sections: Vec::new(),
1253        },
1254        other => Origin::File {
1255            path: String::new(),
1256            format: other.name().to_owned(),
1257            hash: None,
1258            retained_source: net.source.is_some(),
1259        },
1260    }
1261}
1262
1263fn balanced_sources(net: &BalancedNetwork) -> Vec<SourceDescriptor> {
1264    let Some(kind) = balanced_source_kind(net.source_format) else {
1265        return Vec::new();
1266    };
1267    vec![SourceDescriptor {
1268        id: "src0".to_owned(),
1269        kind: kind.to_owned(),
1270        path: None,
1271        format: Some(net.source_format.name().to_owned()),
1272        hash: None,
1273    }]
1274}
1275
1276fn balanced_source_kind(f: SourceFormat) -> Option<&'static str> {
1277    match f {
1278        SourceFormat::InMemory | SourceFormat::Normalized => None,
1279        SourceFormat::Gridfm | SourceFormat::PypsaCsv => Some("folder"),
1280        SourceFormat::PowerWorldBinary => Some("binary_file"),
1281        _ => Some("file"),
1282    }
1283}
1284
1285fn balanced_summary(net: &BalancedNetwork) -> ObjectSummary {
1286    let mut elements = BTreeMap::new();
1287    elements.insert("buses".to_owned(), net.buses.len() as u64);
1288    elements.insert("loads".to_owned(), net.loads.len() as u64);
1289    elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1290    elements.insert("branches".to_owned(), net.branches.len() as u64);
1291    elements.insert("generators".to_owned(), net.generators.len() as u64);
1292    elements.insert("storage".to_owned(), net.storage.len() as u64);
1293    elements.insert("hvdc".to_owned(), net.hvdc.len() as u64);
1294    elements.insert(
1295        "transformers_3w".to_owned(),
1296        net.transformers_3w.len() as u64,
1297    );
1298
1299    let reference_buses: Vec<String> = net
1300        .buses
1301        .iter()
1302        .filter(|b| b.kind == powerio::BusType::Ref)
1303        .map(|b| b.id.0.to_string())
1304        .collect();
1305
1306    ObjectSummary {
1307        elements,
1308        topology: Some(ObjectTopology {
1309            connected_components: None,
1310            reference_buses,
1311        }),
1312        units: Some(ObjectUnits {
1313            power: Some("MW/MVAr".to_owned()),
1314            angle: Some("degrees".to_owned()),
1315            base_mva: Some(net.base_mva),
1316        }),
1317    }
1318}
1319
1320fn balanced_source_maps(net: &BalancedNetwork, source_id: Option<&str>) -> Vec<SourceMapEntry> {
1321    let Some(source_id) = source_id else {
1322        return Vec::new();
1323    };
1324    let mut entries = Vec::new();
1325    push_balanced_network_maps(&mut entries, source_id, net.source_format);
1326    push_balanced_bus_maps(&mut entries, source_id, net.buses.len());
1327    push_balanced_injection_maps(&mut entries, source_id, net);
1328    push_balanced_branch_maps(&mut entries, source_id, net);
1329    push_balanced_generator_maps(&mut entries, source_id, net.generators.len());
1330    entries
1331}
1332
1333fn push_balanced_network_maps(
1334    entries: &mut Vec<SourceMapEntry>,
1335    source_id: &str,
1336    source_format: SourceFormat,
1337) {
1338    push_balanced_map(
1339        entries,
1340        source_id,
1341        "/model/balanced_network/base_mva",
1342        "case",
1343        "base_mva",
1344        MappingKind::Exact,
1345    );
1346    if balanced_has_frequency_source(source_format) {
1347        push_balanced_map(
1348            entries,
1349            source_id,
1350            "/model/balanced_network/base_frequency",
1351            "case",
1352            "base_frequency",
1353            MappingKind::Exact,
1354        );
1355    }
1356}
1357
1358fn push_balanced_bus_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1359    push_balanced_record_maps(
1360        entries,
1361        source_id,
1362        "buses",
1363        len,
1364        "bus",
1365        &[
1366            "id", "kind", "vm", "va", "base_kv", "vmax", "vmin", "area", "zone",
1367        ],
1368        MappingKind::Exact,
1369    );
1370}
1371
1372fn push_balanced_injection_maps(
1373    entries: &mut Vec<SourceMapEntry>,
1374    source_id: &str,
1375    net: &BalancedNetwork,
1376) {
1377    if net.source_format == SourceFormat::Matpower {
1378        push_matpower_injection_maps(entries, source_id, net);
1379    } else {
1380        push_balanced_record_maps(
1381            entries,
1382            source_id,
1383            "loads",
1384            net.loads.len(),
1385            "load",
1386            &["bus", "p", "q", "in_service"],
1387            MappingKind::Exact,
1388        );
1389        push_balanced_record_maps(
1390            entries,
1391            source_id,
1392            "shunts",
1393            net.shunts.len(),
1394            "shunt",
1395            &["bus", "g", "b", "in_service"],
1396            MappingKind::Exact,
1397        );
1398    }
1399}
1400
1401fn push_balanced_branch_maps(
1402    entries: &mut Vec<SourceMapEntry>,
1403    source_id: &str,
1404    net: &BalancedNetwork,
1405) {
1406    for (i, branch) in net.branches.iter().enumerate() {
1407        push_balanced_record_map(
1408            entries,
1409            source_id,
1410            "branches",
1411            i,
1412            "branch",
1413            &[
1414                "from",
1415                "to",
1416                "r",
1417                "x",
1418                "b",
1419                "rate_a",
1420                "rate_b",
1421                "rate_c",
1422                "tap",
1423                "shift",
1424                "in_service",
1425                "angmin",
1426                "angmax",
1427            ],
1428            MappingKind::Exact,
1429        );
1430        if branch.charging.is_some() {
1431            for field in ["g_fr", "b_fr", "g_to", "b_to"] {
1432                push_balanced_map(
1433                    entries,
1434                    source_id,
1435                    &format!("/model/balanced_network/branches/{i}/charging/{field}"),
1436                    "branch",
1437                    field,
1438                    MappingKind::Exact,
1439                );
1440            }
1441        }
1442    }
1443}
1444
1445fn push_balanced_generator_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1446    push_balanced_record_maps(
1447        entries,
1448        source_id,
1449        "generators",
1450        len,
1451        "generator",
1452        &[
1453            "bus",
1454            "pg",
1455            "qg",
1456            "pmax",
1457            "pmin",
1458            "qmax",
1459            "qmin",
1460            "vg",
1461            "mbase",
1462            "in_service",
1463        ],
1464        MappingKind::Exact,
1465    );
1466}
1467
1468fn balanced_has_frequency_source(source_format: SourceFormat) -> bool {
1469    matches!(
1470        source_format,
1471        SourceFormat::Psse | SourceFormat::PandapowerJson
1472    )
1473}
1474
1475fn push_matpower_injection_maps(
1476    entries: &mut Vec<SourceMapEntry>,
1477    source_id: &str,
1478    net: &BalancedNetwork,
1479) {
1480    // MATPOWER folds loads and shunts into the bus record. Keep the source
1481    // field token canonical like the rest of the balanced source maps; the
1482    // record and mapping kind carry the folded-row relationship.
1483    push_balanced_record_maps(
1484        entries,
1485        source_id,
1486        "loads",
1487        net.loads.len(),
1488        "bus",
1489        &["bus", "p", "q", "in_service"],
1490        MappingKind::Split,
1491    );
1492    push_balanced_record_maps(
1493        entries,
1494        source_id,
1495        "shunts",
1496        net.shunts.len(),
1497        "bus",
1498        &["bus", "g", "b", "in_service"],
1499        MappingKind::Split,
1500    );
1501}
1502
1503fn push_balanced_record_maps(
1504    entries: &mut Vec<SourceMapEntry>,
1505    source_id: &str,
1506    collection: &str,
1507    len: usize,
1508    record: &str,
1509    fields: &[&str],
1510    mapping_kind: MappingKind,
1511) {
1512    for i in 0..len {
1513        push_balanced_record_map(
1514            entries,
1515            source_id,
1516            collection,
1517            i,
1518            record,
1519            fields,
1520            mapping_kind,
1521        );
1522    }
1523}
1524
1525fn push_balanced_record_map(
1526    entries: &mut Vec<SourceMapEntry>,
1527    source_id: &str,
1528    collection: &str,
1529    i: usize,
1530    record: &str,
1531    fields: &[&str],
1532    mapping_kind: MappingKind,
1533) {
1534    for &field in fields {
1535        push_balanced_map(
1536            entries,
1537            source_id,
1538            &format!("/model/balanced_network/{collection}/{i}/{field}"),
1539            record,
1540            field,
1541            mapping_kind,
1542        );
1543    }
1544}
1545
1546fn push_balanced_map(
1547    entries: &mut Vec<SourceMapEntry>,
1548    source_id: &str,
1549    element_path: &str,
1550    record: &str,
1551    field: &str,
1552    mapping_kind: MappingKind,
1553) {
1554    entries.push(SourceMapEntry {
1555        element_path: element_path.to_owned(),
1556        source_ref: SourceRef::new(source_id)
1557            .with_record(record)
1558            .with_field(field),
1559        mapping_kind,
1560        confidence: Confidence::High,
1561    });
1562}
1563
1564fn multiconductor_summary(net: &MulticonductorNetwork) -> ObjectSummary {
1565    let mut elements = BTreeMap::new();
1566    elements.insert("buses".to_owned(), net.buses.len() as u64);
1567    elements.insert("linecodes".to_owned(), net.linecodes.len() as u64);
1568    elements.insert("lines".to_owned(), net.lines.len() as u64);
1569    elements.insert("switches".to_owned(), net.switches.len() as u64);
1570    elements.insert("transformers".to_owned(), net.transformers.len() as u64);
1571    elements.insert("loads".to_owned(), net.loads.len() as u64);
1572    elements.insert("generators".to_owned(), net.generators.len() as u64);
1573    elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1574    elements.insert("voltage_sources".to_owned(), net.sources.len() as u64);
1575
1576    ObjectSummary {
1577        elements,
1578        topology: None,
1579        units: Some(ObjectUnits {
1580            power: Some("W/var".to_owned()),
1581            angle: Some("radians".to_owned()),
1582            base_mva: None,
1583        }),
1584    }
1585}
1586
1587fn multiconductor_sources(net: &MulticonductorNetwork) -> Vec<SourceDescriptor> {
1588    match net.source_format {
1589        Some(sf) => vec![SourceDescriptor {
1590            id: "src0".to_owned(),
1591            kind: "file".to_owned(),
1592            path: None,
1593            format: Some(dist_format_name(sf).to_owned()),
1594            hash: None,
1595        }],
1596        None => Vec::new(),
1597    }
1598}
1599
1600fn dist_format_name(f: DistSourceFormat) -> &'static str {
1601    f.name()
1602}
1603
1604fn multiconductor_origin(net: &MulticonductorNetwork) -> Origin {
1605    match net.source_format {
1606        Some(sf) => Origin::File {
1607            path: String::new(),
1608            format: dist_format_name(sf).to_owned(),
1609            hash: None,
1610            retained_source: net.source.is_some(),
1611        },
1612        None => Origin::InMemory,
1613    }
1614}
1615
1616fn derived_sources(parent: &NetworkPackage) -> Vec<SourceDescriptor> {
1617    if !parent.sources.is_empty() {
1618        return parent.sources.clone();
1619    }
1620    vec![SourceDescriptor {
1621        id: "parent".to_owned(),
1622        kind: "package".to_owned(),
1623        path: None,
1624        format: Some("pio-json".to_owned()),
1625        hash: parent.package_id.clone(),
1626    }]
1627}
1628
1629fn lowered_balanced_source_maps(
1630    input: &MulticonductorNetwork,
1631    balanced: &BalancedNetwork,
1632    source_id: Option<&str>,
1633) -> Vec<SourceMapEntry> {
1634    let Some(source_id) = source_id else {
1635        return Vec::new();
1636    };
1637    let mut entries = Vec::new();
1638    push_lowered_bus_maps(&mut entries, source_id, input);
1639    push_lowered_branch_maps(&mut entries, source_id, input, balanced);
1640    push_lowered_load_maps(&mut entries, source_id, input, balanced);
1641    push_lowered_shunt_maps(&mut entries, source_id, input, balanced);
1642    push_lowered_generator_maps(&mut entries, source_id, input, balanced);
1643    entries
1644}
1645
1646fn push_lowered_bus_maps(
1647    entries: &mut Vec<SourceMapEntry>,
1648    source_id: &str,
1649    input: &MulticonductorNetwork,
1650) {
1651    for (idx, bus) in input.buses.iter().enumerate() {
1652        for (field, mapping_kind) in [
1653            ("id", MappingKind::Synthetic),
1654            ("kind", MappingKind::Lowered),
1655            ("vm", MappingKind::ConvertedUnits),
1656            ("va", MappingKind::ConvertedUnits),
1657            ("base_kv", MappingKind::ConvertedUnits),
1658            ("area", MappingKind::Defaulted),
1659            ("zone", MappingKind::Defaulted),
1660            ("name", MappingKind::Lowered),
1661        ] {
1662            push_lowered_map(
1663                entries,
1664                source_id,
1665                &format!("/model/balanced_network/buses/{idx}/{field}"),
1666                "multiconductor_bus",
1667                field,
1668                mapping_kind,
1669            );
1670        }
1671        for field in ["vmin", "vmax"] {
1672            let mapping_kind = if bus.v_min.is_some() && bus.v_max.is_some() {
1673                MappingKind::ConvertedUnits
1674            } else {
1675                MappingKind::Defaulted
1676            };
1677            push_lowered_map(
1678                entries,
1679                source_id,
1680                &format!("/model/balanced_network/buses/{idx}/{field}"),
1681                "multiconductor_bus",
1682                field,
1683                mapping_kind,
1684            );
1685        }
1686    }
1687}
1688
1689fn push_lowered_branch_maps(
1690    entries: &mut Vec<SourceMapEntry>,
1691    source_id: &str,
1692    input: &MulticonductorNetwork,
1693    balanced: &BalancedNetwork,
1694) {
1695    for (idx, branch) in balanced.branches.iter().enumerate() {
1696        let record = "multiconductor_line";
1697        for (field, mapping_kind) in [
1698            ("from", MappingKind::Lowered),
1699            ("to", MappingKind::Lowered),
1700            ("r", MappingKind::ConvertedUnits),
1701            ("x", MappingKind::ConvertedUnits),
1702            ("b", MappingKind::ConvertedUnits),
1703            ("in_service", MappingKind::Lowered),
1704            ("tap", MappingKind::Defaulted),
1705            ("shift", MappingKind::Defaulted),
1706            ("angmin", MappingKind::Defaulted),
1707            ("angmax", MappingKind::Defaulted),
1708        ] {
1709            push_lowered_map(
1710                entries,
1711                source_id,
1712                &format!("/model/balanced_network/branches/{idx}/{field}"),
1713                record,
1714                field,
1715                mapping_kind,
1716            );
1717        }
1718        let has_rating = input
1719            .lines
1720            .get(idx)
1721            .and_then(|line| input.linecode(&line.linecode))
1722            .is_some_and(|code| code.i_max.is_some() || code.s_max.is_some());
1723        let rate_kind = if has_rating {
1724            MappingKind::ConvertedUnits
1725        } else {
1726            MappingKind::Defaulted
1727        };
1728        for field in ["rate_a", "rate_b", "rate_c"] {
1729            push_lowered_map(
1730                entries,
1731                source_id,
1732                &format!("/model/balanced_network/branches/{idx}/{field}"),
1733                record,
1734                field,
1735                rate_kind,
1736            );
1737        }
1738        if branch.charging.is_some() {
1739            for field in ["g_fr", "b_fr", "g_to", "b_to"] {
1740                push_lowered_map(
1741                    entries,
1742                    source_id,
1743                    &format!("/model/balanced_network/branches/{idx}/charging/{field}"),
1744                    record,
1745                    field,
1746                    MappingKind::ConvertedUnits,
1747                );
1748            }
1749        }
1750    }
1751}
1752
1753fn push_lowered_load_maps(
1754    entries: &mut Vec<SourceMapEntry>,
1755    source_id: &str,
1756    input: &MulticonductorNetwork,
1757    balanced: &BalancedNetwork,
1758) {
1759    for idx in 0..balanced.loads.len().min(input.loads.len()) {
1760        for (field, mapping_kind) in [
1761            ("bus", MappingKind::Lowered),
1762            ("p", MappingKind::Aggregated),
1763            ("q", MappingKind::Aggregated),
1764            ("in_service", MappingKind::Lowered),
1765        ] {
1766            push_lowered_map(
1767                entries,
1768                source_id,
1769                &format!("/model/balanced_network/loads/{idx}/{field}"),
1770                "multiconductor_load",
1771                field,
1772                mapping_kind,
1773            );
1774        }
1775    }
1776}
1777
1778fn push_lowered_shunt_maps(
1779    entries: &mut Vec<SourceMapEntry>,
1780    source_id: &str,
1781    input: &MulticonductorNetwork,
1782    balanced: &BalancedNetwork,
1783) {
1784    for idx in 0..balanced.shunts.len().min(input.shunts.len()) {
1785        for (field, mapping_kind) in [
1786            ("bus", MappingKind::Lowered),
1787            ("g", MappingKind::Aggregated),
1788            ("b", MappingKind::Aggregated),
1789            ("in_service", MappingKind::Lowered),
1790        ] {
1791            push_lowered_map(
1792                entries,
1793                source_id,
1794                &format!("/model/balanced_network/shunts/{idx}/{field}"),
1795                "multiconductor_shunt",
1796                field,
1797                mapping_kind,
1798            );
1799        }
1800    }
1801}
1802
1803fn push_lowered_generator_maps(
1804    entries: &mut Vec<SourceMapEntry>,
1805    source_id: &str,
1806    input: &MulticonductorNetwork,
1807    balanced: &BalancedNetwork,
1808) {
1809    for idx in 0..balanced.generators.len().min(input.generators.len()) {
1810        let generator = &input.generators[idx];
1811        for (field, mapping_kind) in [
1812            ("bus", MappingKind::Lowered),
1813            ("pg", MappingKind::Aggregated),
1814            ("qg", MappingKind::Aggregated),
1815            ("vg", MappingKind::Defaulted),
1816            ("mbase", MappingKind::Synthetic),
1817            ("in_service", MappingKind::Lowered),
1818        ] {
1819            push_lowered_map(
1820                entries,
1821                source_id,
1822                &format!("/model/balanced_network/generators/{idx}/{field}"),
1823                "multiconductor_generator",
1824                field,
1825                mapping_kind,
1826            );
1827        }
1828        for (field, present) in [
1829            ("pmin", generator.p_min.is_some()),
1830            ("pmax", generator.p_max.is_some()),
1831            ("qmin", generator.q_min.is_some()),
1832            ("qmax", generator.q_max.is_some()),
1833        ] {
1834            push_lowered_map(
1835                entries,
1836                source_id,
1837                &format!("/model/balanced_network/generators/{idx}/{field}"),
1838                "multiconductor_generator",
1839                field,
1840                if present {
1841                    MappingKind::Aggregated
1842                } else {
1843                    MappingKind::Defaulted
1844                },
1845            );
1846        }
1847    }
1848}
1849
1850fn push_lowered_map(
1851    entries: &mut Vec<SourceMapEntry>,
1852    source_id: &str,
1853    element_path: &str,
1854    record: &str,
1855    field: &str,
1856    mapping_kind: MappingKind,
1857) {
1858    entries.push(SourceMapEntry {
1859        element_path: element_path.to_owned(),
1860        source_ref: SourceRef::new(source_id)
1861            .with_record(record)
1862            .with_field(field),
1863        mapping_kind,
1864        confidence: Confidence::High,
1865    });
1866}
1867
1868/// Lift the `defaulted` map into source-map entries with `mapping_kind =
1869/// defaulted`. Each key is `"class.name"`; each value is the list of fields the
1870/// reader materialized from a format default. The element path is a best-effort
1871/// locator (a precise JSON pointer into the payload arrays is future work).
1872fn multiconductor_source_maps(
1873    net: &MulticonductorNetwork,
1874    source_id: Option<&str>,
1875) -> Vec<SourceMapEntry> {
1876    let Some(source_id) = source_id else {
1877        return Vec::new();
1878    };
1879    let mut entries = Vec::new();
1880    for (element, fields) in &net.defaulted {
1881        for field in fields {
1882            entries.push(SourceMapEntry {
1883                element_path: format!("/model/multiconductor_network/{element}#{field}"),
1884                source_ref: SourceRef::new(source_id).with_field((*field).to_owned()),
1885                mapping_kind: MappingKind::Defaulted,
1886                confidence: Confidence::High,
1887            });
1888        }
1889    }
1890    entries
1891}