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