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