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; 10] = [
947    "VALIDATE.BALANCED.STRUCTURE",
948    "VALIDATE.BALANCED.VALUE_DOMAIN",
949    "VALIDATE.BALANCED.PAYLOAD_IDENTITY",
950    "VALIDATE.MULTI.STRUCTURE",
951    "VALIDATE.MULTI.TERMINAL_MAP",
952    "VALIDATE.MULTI.UNTYPED_OBJECT",
953    "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
954    "VALIDATE.PACKAGE.OPERATING_IDENTITY",
955    "VALIDATE.PACKAGE.STUDY_MODEL_KIND",
956    "VALIDATE.PACKAGE.STUDY_IDENTITY",
957];
958
959/// Check every operating point update against the payload's identity index:
960/// unknown `source_uid`, a wire `row` that contradicts the resolved row,
961/// ambiguous (duplicate) payload uids, and rows out of range all become Error
962/// diagnostics, so `pio_package_validate` rejects a package whose updates
963/// reference unknown identities without materializing it.
964fn validate_operating_identity(
965    model: &ModelPayload,
966    series: &OperatingPointSeries,
967) -> (Vec<StructuredDiagnostic>, ValidationPass) {
968    let diagnostics: Vec<StructuredDiagnostic> = check_series_identities(model, series)
969        .into_iter()
970        .map(|(point_pos, update_pos, message)| {
971            StructuredDiagnostic::new(
972                "VALIDATE.PACKAGE.OPERATING_IDENTITY",
973                DiagnosticSeverity::Error,
974                DiagnosticStage::Validate,
975                message,
976            )
977            .with_element_path(format!(
978                "/operating_points/points/{point_pos}/updates/{update_pos}"
979            ))
980        })
981        .collect();
982    let status = validation_status(&diagnostics);
983    (
984        diagnostics,
985        ValidationPass::new("package.operating_identity", status),
986    )
987}
988
989fn validate_study(
990    model: &ModelPayload,
991    study: &StudyBlock,
992) -> (Vec<StructuredDiagnostic>, ValidationPass) {
993    if !matches!(model, ModelPayload::Balanced { .. }) {
994        let diagnostics = vec![
995            StructuredDiagnostic::new(
996                "VALIDATE.PACKAGE.STUDY_MODEL_KIND",
997                DiagnosticSeverity::Error,
998                DiagnosticStage::Validate,
999                "study blocks are only defined for balanced packages",
1000            )
1001            .with_element_path("/study"),
1002        ];
1003        return (
1004            diagnostics,
1005            ValidationPass::new("package.study", ValidationStatus::Error),
1006        );
1007    }
1008
1009    let diagnostics: Vec<StructuredDiagnostic> = check_study_identities(model, study)
1010        .into_iter()
1011        .map(|(commit_pos, edit_pos, message)| {
1012            StructuredDiagnostic::new(
1013                "VALIDATE.PACKAGE.STUDY_IDENTITY",
1014                DiagnosticSeverity::Error,
1015                DiagnosticStage::Validate,
1016                message,
1017            )
1018            .with_element_path(format!("/study/commits/{commit_pos}/edits/{edit_pos}"))
1019        })
1020        .collect();
1021    let status = validation_status(&diagnostics);
1022    (
1023        diagnostics,
1024        ValidationPass::new("package.study_identity", status),
1025    )
1026}
1027
1028fn is_sane_validation_code(code: &str) -> bool {
1029    SANE_VALIDATION_CODES.contains(&code)
1030}
1031
1032fn validation_status(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
1033    diagnostics
1034        .iter()
1035        .map(|d| match d.severity {
1036            DiagnosticSeverity::Debug => ValidationStatus::Ok,
1037            DiagnosticSeverity::Info => ValidationStatus::Info,
1038            DiagnosticSeverity::Warning => ValidationStatus::Warning,
1039            DiagnosticSeverity::Error => ValidationStatus::Error,
1040            DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
1041        })
1042        .max()
1043        .unwrap_or(ValidationStatus::Ok)
1044}
1045
1046fn sane_validate_balanced(
1047    net: &BalancedNetwork,
1048) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
1049    let mut structure = Vec::new();
1050    if let Err(err) = net.validate() {
1051        structure.push(StructuredDiagnostic::new(
1052            "VALIDATE.BALANCED.STRUCTURE",
1053            DiagnosticSeverity::Error,
1054            DiagnosticStage::Validate,
1055            err.to_string(),
1056        ));
1057    }
1058
1059    let bus_index: HashMap<usize, usize> = net
1060        .buses
1061        .iter()
1062        .enumerate()
1063        .map(|(idx, b)| (b.id.0, idx))
1064        .collect();
1065    let mut value_domain = Vec::new();
1066    for finding in net.validate_values() {
1067        let element_path =
1068            balanced_value_finding_path(net, &bus_index, &finding).unwrap_or_else(|| {
1069                format!(
1070                    "/model/balanced_network/{}#{}",
1071                    finding.element.replace(' ', "_"),
1072                    finding.field
1073                )
1074            });
1075        let mut d = StructuredDiagnostic::new(
1076            "VALIDATE.BALANCED.VALUE_DOMAIN",
1077            DiagnosticSeverity::Warning,
1078            DiagnosticStage::Validate,
1079            format!(
1080                "{} field `{}` is outside its value domain; suggested value is {}",
1081                finding.element, finding.field, finding.new
1082            ),
1083        )
1084        .with_element_path(element_path)
1085        .with_suggested_action("Run the explicit repair pass if these defaults are desired.");
1086        d.details
1087            .insert("element".to_owned(), serde_json::json!(finding.element));
1088        d.details
1089            .insert("field".to_owned(), serde_json::json!(finding.field));
1090        d.details
1091            .insert("old".to_owned(), serde_json::json!(finding.old));
1092        d.details
1093            .insert("new".to_owned(), serde_json::json!(finding.new));
1094        d.details
1095            .insert("reason".to_owned(), serde_json::json!(finding.reason));
1096        value_domain.push(d);
1097    }
1098
1099    // References resolve by uid, and `ensure_payload_uids` can mint a
1100    // `{table}:{row}` value that collides with a source-supplied one.
1101    // Diagnose the collision here, at validation time.
1102    let mut identity = Vec::new();
1103    macro_rules! check_uids {
1104        ($table:ident) => {
1105            table_uid_duplicates(
1106                stringify!($table),
1107                net.$table.iter().map(|e| e.uid.as_deref()),
1108                &mut identity,
1109            )
1110        };
1111    }
1112    check_uids!(buses);
1113    check_uids!(loads);
1114    check_uids!(shunts);
1115    check_uids!(branches);
1116    check_uids!(switches);
1117    check_uids!(generators);
1118    check_uids!(storage);
1119    check_uids!(hvdc);
1120    check_uids!(transformers_3w);
1121
1122    let passes = vec![
1123        ValidationPass::new("balanced.structure", validation_status(&structure)),
1124        ValidationPass::new("balanced.value_domain", validation_status(&value_domain)),
1125        ValidationPass::new("balanced.payload_identity", validation_status(&identity)),
1126    ];
1127    structure.extend(value_domain);
1128    structure.extend(identity);
1129    (structure, passes)
1130}
1131
1132/// One Error diagnostic per row that repeats an earlier row's uid in `table`.
1133/// A repeated uid makes every identity-based reference to it ambiguous (the
1134/// same condition `resolve_update_row` rejects during application).
1135fn table_uid_duplicates<'a>(
1136    table: &str,
1137    uids: impl Iterator<Item = Option<&'a str>>,
1138    diagnostics: &mut Vec<StructuredDiagnostic>,
1139) {
1140    let mut first_row: HashMap<&str, usize> = HashMap::new();
1141    for (row, uid) in uids.enumerate() {
1142        let Some(uid) = uid else { continue };
1143        if let Some(&first) = first_row.get(uid) {
1144            diagnostics.push(
1145                StructuredDiagnostic::new(
1146                    "VALIDATE.BALANCED.PAYLOAD_IDENTITY",
1147                    DiagnosticSeverity::Error,
1148                    DiagnosticStage::Validate,
1149                    format!(
1150                        "payload table `{table}` carries uid `{uid}` on rows {first} and {row}; \
1151                         identity resolution is ambiguous"
1152                    ),
1153                )
1154                .with_element_path(format!("/model/balanced_network/{table}/{row}/uid")),
1155            );
1156        } else {
1157            first_row.insert(uid, row);
1158        }
1159    }
1160}
1161
1162fn attach_source_refs(diagnostics: &mut [StructuredDiagnostic], source_maps: &[SourceMapEntry]) {
1163    // Index by element path once: `source_maps` holds a row per field per
1164    // element, so a per-diagnostic linear scan is quadratic. First entry wins,
1165    // matching the previous `iter().find` order.
1166    let mut by_path: HashMap<&str, &SourceRef> = HashMap::with_capacity(source_maps.len());
1167    for map in source_maps {
1168        by_path
1169            .entry(map.element_path.as_str())
1170            .or_insert(&map.source_ref);
1171    }
1172    for diagnostic in diagnostics {
1173        if diagnostic.source_ref.is_some() {
1174            continue;
1175        }
1176        let Some(path) = diagnostic.element_path.as_deref() else {
1177            continue;
1178        };
1179        if let Some(source_ref) = by_path.get(path) {
1180            diagnostic.source_ref = Some((*source_ref).clone());
1181        }
1182    }
1183}
1184
1185fn balanced_value_finding_path(
1186    net: &BalancedNetwork,
1187    bus_index: &HashMap<usize, usize>,
1188    finding: &powerio::Diagnostic,
1189) -> Option<String> {
1190    if let Some(id) = finding
1191        .element
1192        .strip_prefix("bus ")
1193        .and_then(|s| s.parse::<usize>().ok())
1194    {
1195        let idx = *bus_index.get(&id)?;
1196        return Some(format!(
1197            "/model/balanced_network/buses/{idx}/{}",
1198            finding.field
1199        ));
1200    }
1201
1202    if let Some(id) = finding
1203        .element
1204        .strip_prefix("generator at bus ")
1205        .and_then(|s| s.parse::<usize>().ok())
1206    {
1207        // When several units at a bus share the same out-of-domain value the
1208        // finding cannot be pinned to one array index, so skip the precise path
1209        // rather than misattribute it (see the ambiguity test).
1210        let mut matches = net
1211            .generators
1212            .iter()
1213            .enumerate()
1214            .filter(|(_, g)| {
1215                g.bus.0 == id
1216                    && generator_field(g, finding.field)
1217                        .is_some_and(|v| v.to_bits() == finding.old.to_bits())
1218            })
1219            .map(|(idx, _)| idx);
1220        let idx = matches.next()?;
1221        if matches.next().is_some() {
1222            return None;
1223        }
1224        return Some(format!(
1225            "/model/balanced_network/generators/{idx}/{}",
1226            finding.field
1227        ));
1228    }
1229
1230    None
1231}
1232
1233fn generator_field(generator: &powerio::Generator, field: &str) -> Option<f64> {
1234    Some(match field {
1235        "mbase" => generator.mbase,
1236        "vg" => generator.vg,
1237        _ => return None,
1238    })
1239}
1240
1241fn sane_validate_multiconductor(
1242    net: &MulticonductorNetwork,
1243) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
1244    let mut structure = Vec::new();
1245    let mut terminal_maps = Vec::new();
1246    let mut untyped = Vec::new();
1247    let mut sources = Vec::new();
1248
1249    let (bus_ids, bus_terminals) = multiconductor_bus_index(net, &mut structure);
1250
1251    validate_multiconductor_lines(
1252        net,
1253        &bus_ids,
1254        &bus_terminals,
1255        &mut structure,
1256        &mut terminal_maps,
1257    );
1258    validate_multiconductor_switches(
1259        net,
1260        &bus_ids,
1261        &bus_terminals,
1262        &mut structure,
1263        &mut terminal_maps,
1264    );
1265    validate_multiconductor_transformers(
1266        net,
1267        &bus_ids,
1268        &bus_terminals,
1269        &mut structure,
1270        &mut terminal_maps,
1271    );
1272    validate_multiconductor_injections(
1273        net,
1274        &bus_ids,
1275        &bus_terminals,
1276        &mut structure,
1277        &mut terminal_maps,
1278    );
1279
1280    for (i, obj) in net.untyped.iter().enumerate() {
1281        untyped.push(
1282            StructuredDiagnostic::new(
1283                "VALIDATE.MULTI.UNTYPED_OBJECT",
1284                DiagnosticSeverity::Warning,
1285                DiagnosticStage::Validate,
1286                format!(
1287                    "{} {} is preserved as an untyped object",
1288                    obj.class, obj.name
1289                ),
1290            )
1291            .with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
1292        );
1293    }
1294
1295    if net.sources.is_empty() {
1296        sources.push(StructuredDiagnostic::new(
1297            "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
1298            DiagnosticSeverity::Warning,
1299            DiagnosticStage::Validate,
1300            "multiconductor package has no voltage source",
1301        ));
1302    }
1303
1304    let passes = vec![
1305        ValidationPass::new("multiconductor.structure", validation_status(&structure)),
1306        ValidationPass::new(
1307            "multiconductor.terminal_map",
1308            validation_status(&terminal_maps),
1309        ),
1310        ValidationPass::new("multiconductor.untyped_object", validation_status(&untyped)),
1311        ValidationPass::new("multiconductor.voltage_source", validation_status(&sources)),
1312    ];
1313
1314    let mut diagnostics = structure;
1315    diagnostics.extend(terminal_maps);
1316    diagnostics.extend(untyped);
1317    diagnostics.extend(sources);
1318    (diagnostics, passes)
1319}
1320
1321fn validate_multiconductor_lines(
1322    net: &MulticonductorNetwork,
1323    bus_ids: &BTreeSet<String>,
1324    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1325    structure: &mut Vec<StructuredDiagnostic>,
1326    terminal_maps: &mut Vec<StructuredDiagnostic>,
1327) {
1328    for (i, line) in net.lines.iter().enumerate() {
1329        check_bus_ref(
1330            &line.bus_from,
1331            &format!("line {} from bus", line.name),
1332            &format!("/model/multiconductor_network/lines/{i}/bus_from"),
1333            bus_ids,
1334            structure,
1335        );
1336        check_bus_ref(
1337            &line.bus_to,
1338            &format!("line {} to bus", line.name),
1339            &format!("/model/multiconductor_network/lines/{i}/bus_to"),
1340            bus_ids,
1341            structure,
1342        );
1343        if !net
1344            .linecodes
1345            .iter()
1346            .any(|c| c.name.eq_ignore_ascii_case(&line.linecode))
1347        {
1348            structure.push(
1349                StructuredDiagnostic::new(
1350                    "VALIDATE.MULTI.STRUCTURE",
1351                    DiagnosticSeverity::Error,
1352                    DiagnosticStage::Validate,
1353                    format!(
1354                        "line {} references unknown linecode `{}`",
1355                        line.name, line.linecode
1356                    ),
1357                )
1358                .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1359            );
1360        }
1361        check_terminal_map(
1362            &line.bus_from,
1363            &line.terminal_map_from,
1364            &format!("line {} from terminals", line.name),
1365            &format!("/model/multiconductor_network/lines/{i}/terminal_map_from"),
1366            bus_terminals,
1367            terminal_maps,
1368        );
1369        check_terminal_map(
1370            &line.bus_to,
1371            &line.terminal_map_to,
1372            &format!("line {} to terminals", line.name),
1373            &format!("/model/multiconductor_network/lines/{i}/terminal_map_to"),
1374            bus_terminals,
1375            terminal_maps,
1376        );
1377    }
1378}
1379
1380fn validate_multiconductor_switches(
1381    net: &MulticonductorNetwork,
1382    bus_ids: &BTreeSet<String>,
1383    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1384    structure: &mut Vec<StructuredDiagnostic>,
1385    terminal_maps: &mut Vec<StructuredDiagnostic>,
1386) {
1387    for (i, sw) in net.switches.iter().enumerate() {
1388        check_bus_ref(
1389            &sw.bus_from,
1390            &format!("switch {} from bus", sw.name),
1391            &format!("/model/multiconductor_network/switches/{i}/bus_from"),
1392            bus_ids,
1393            structure,
1394        );
1395        check_bus_ref(
1396            &sw.bus_to,
1397            &format!("switch {} to bus", sw.name),
1398            &format!("/model/multiconductor_network/switches/{i}/bus_to"),
1399            bus_ids,
1400            structure,
1401        );
1402        check_terminal_map(
1403            &sw.bus_from,
1404            &sw.terminal_map_from,
1405            &format!("switch {} from terminals", sw.name),
1406            &format!("/model/multiconductor_network/switches/{i}/terminal_map_from"),
1407            bus_terminals,
1408            terminal_maps,
1409        );
1410        check_terminal_map(
1411            &sw.bus_to,
1412            &sw.terminal_map_to,
1413            &format!("switch {} to terminals", sw.name),
1414            &format!("/model/multiconductor_network/switches/{i}/terminal_map_to"),
1415            bus_terminals,
1416            terminal_maps,
1417        );
1418    }
1419}
1420
1421fn validate_multiconductor_transformers(
1422    net: &MulticonductorNetwork,
1423    bus_ids: &BTreeSet<String>,
1424    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1425    structure: &mut Vec<StructuredDiagnostic>,
1426    terminal_maps: &mut Vec<StructuredDiagnostic>,
1427) {
1428    for (i, tx) in net.transformers.iter().enumerate() {
1429        for (j, winding) in tx.windings.iter().enumerate() {
1430            check_bus_ref(
1431                &winding.bus,
1432                &format!("transformer {} winding {j} bus", tx.name),
1433                &format!("/model/multiconductor_network/transformers/{i}/windings/{j}/bus"),
1434                bus_ids,
1435                structure,
1436            );
1437            check_terminal_map(
1438                &winding.bus,
1439                &winding.terminal_map,
1440                &format!("transformer {} winding {j} terminals", tx.name),
1441                &format!(
1442                    "/model/multiconductor_network/transformers/{i}/windings/{j}/terminal_map"
1443                ),
1444                bus_terminals,
1445                terminal_maps,
1446            );
1447        }
1448    }
1449}
1450
1451fn validate_multiconductor_injections(
1452    net: &MulticonductorNetwork,
1453    bus_ids: &BTreeSet<String>,
1454    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1455    structure: &mut Vec<StructuredDiagnostic>,
1456    terminal_maps: &mut Vec<StructuredDiagnostic>,
1457) {
1458    let mut ctx = MultiValidationContext {
1459        bus_ids,
1460        bus_terminals,
1461        structure,
1462        terminal_maps,
1463    };
1464    for (i, load) in net.loads.iter().enumerate() {
1465        check_one_bus_element(
1466            &load.bus,
1467            &load.terminal_map,
1468            &format!("load {}", load.name),
1469            &format!("/model/multiconductor_network/loads/{i}"),
1470            &mut ctx,
1471        );
1472    }
1473    for (i, generator) in net.generators.iter().enumerate() {
1474        check_one_bus_element(
1475            &generator.bus,
1476            &generator.terminal_map,
1477            &format!("generator {}", generator.name),
1478            &format!("/model/multiconductor_network/generators/{i}"),
1479            &mut ctx,
1480        );
1481    }
1482    for (i, shunt) in net.shunts.iter().enumerate() {
1483        check_one_bus_element(
1484            &shunt.bus,
1485            &shunt.terminal_map,
1486            &format!("shunt {}", shunt.name),
1487            &format!("/model/multiconductor_network/shunts/{i}"),
1488            &mut ctx,
1489        );
1490    }
1491    for (i, capacitor) in net.capacitors.iter().enumerate() {
1492        check_one_bus_element(
1493            &capacitor.bus,
1494            &capacitor.terminal_map,
1495            &format!("capacitor {}", capacitor.name),
1496            &format!("/model/multiconductor_network/capacitors/{i}"),
1497            &mut ctx,
1498        );
1499    }
1500    for (i, source) in net.sources.iter().enumerate() {
1501        check_one_bus_element(
1502            &source.bus,
1503            &source.terminal_map,
1504            &format!("voltage source {}", source.name),
1505            &format!("/model/multiconductor_network/sources/{i}"),
1506            &mut ctx,
1507        );
1508    }
1509}
1510
1511struct MultiValidationContext<'a> {
1512    bus_ids: &'a BTreeSet<String>,
1513    bus_terminals: &'a BTreeMap<String, BTreeSet<String>>,
1514    structure: &'a mut Vec<StructuredDiagnostic>,
1515    terminal_maps: &'a mut Vec<StructuredDiagnostic>,
1516}
1517
1518fn check_one_bus_element(
1519    bus: &str,
1520    terminal_map: &[String],
1521    label: &str,
1522    path: &str,
1523    ctx: &mut MultiValidationContext<'_>,
1524) {
1525    check_bus_ref(
1526        bus,
1527        &format!("{label} bus"),
1528        &format!("{path}/bus"),
1529        ctx.bus_ids,
1530        ctx.structure,
1531    );
1532    check_terminal_map(
1533        bus,
1534        terminal_map,
1535        &format!("{label} terminals"),
1536        &format!("{path}/terminal_map"),
1537        ctx.bus_terminals,
1538        ctx.terminal_maps,
1539    );
1540}
1541
1542fn multiconductor_bus_index(
1543    net: &MulticonductorNetwork,
1544    diagnostics: &mut Vec<StructuredDiagnostic>,
1545) -> (BTreeSet<String>, BTreeMap<String, BTreeSet<String>>) {
1546    let mut ids = BTreeSet::new();
1547    let mut terminals = BTreeMap::new();
1548    let mut first_seen = BTreeMap::<String, String>::new();
1549    for (i, bus) in net.buses.iter().enumerate() {
1550        let key = bus.id.to_ascii_lowercase();
1551        if let Some(first) = first_seen.insert(key.clone(), bus.id.clone()) {
1552            diagnostics.push(
1553                StructuredDiagnostic::new(
1554                    "VALIDATE.MULTI.STRUCTURE",
1555                    DiagnosticSeverity::Error,
1556                    DiagnosticStage::Validate,
1557                    format!("duplicate bus id `{}` conflicts with `{first}`", bus.id),
1558                )
1559                .with_element_path(format!("/model/multiconductor_network/buses/{i}/id")),
1560            );
1561        }
1562        ids.insert(key.clone());
1563        terminals.insert(key, bus.terminals.iter().cloned().collect());
1564    }
1565    (ids, terminals)
1566}
1567
1568fn check_bus_ref(
1569    bus: &str,
1570    what: &str,
1571    path: &str,
1572    bus_ids: &BTreeSet<String>,
1573    diagnostics: &mut Vec<StructuredDiagnostic>,
1574) {
1575    if !bus_ids.contains(&bus.to_ascii_lowercase()) {
1576        diagnostics.push(
1577            StructuredDiagnostic::new(
1578                "VALIDATE.MULTI.STRUCTURE",
1579                DiagnosticSeverity::Error,
1580                DiagnosticStage::Validate,
1581                format!("{what} references unknown bus `{bus}`"),
1582            )
1583            .with_element_path(path),
1584        );
1585    }
1586}
1587
1588fn check_terminal_map(
1589    bus: &str,
1590    terminal_map: &[String],
1591    what: &str,
1592    path: &str,
1593    bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1594    diagnostics: &mut Vec<StructuredDiagnostic>,
1595) {
1596    if terminal_map.is_empty() {
1597        diagnostics.push(
1598            StructuredDiagnostic::new(
1599                "VALIDATE.MULTI.TERMINAL_MAP",
1600                DiagnosticSeverity::Error,
1601                DiagnosticStage::Validate,
1602                format!("{what} has an empty terminal map"),
1603            )
1604            .with_element_path(path),
1605        );
1606        return;
1607    }
1608
1609    let Some(known) = bus_terminals.get(&bus.to_ascii_lowercase()) else {
1610        return;
1611    };
1612    for terminal in terminal_map {
1613        if !known.contains(terminal) {
1614            diagnostics.push(
1615                StructuredDiagnostic::new(
1616                    "VALIDATE.MULTI.TERMINAL_MAP",
1617                    DiagnosticSeverity::Error,
1618                    DiagnosticStage::Validate,
1619                    format!("{what} references unknown terminal `{terminal}` on bus `{bus}`"),
1620                )
1621                .with_element_path(path),
1622            );
1623        }
1624    }
1625}
1626
1627/// Canonical format name for a balanced source format.
1628fn balanced_origin(net: &BalancedNetwork) -> Origin {
1629    match net.source_format {
1630        SourceFormat::InMemory => Origin::InMemory,
1631        SourceFormat::Normalized => Origin::Derived {
1632            parent_package_id: None,
1633            pass: "normalize-balanced".to_owned(),
1634            options: serde_json::Map::new(),
1635        },
1636        SourceFormat::Gridfm | SourceFormat::PypsaCsv => Origin::Folder {
1637            path: String::new(),
1638            format: net.source_format.name().to_owned(),
1639            file_hashes: BTreeMap::new(),
1640        },
1641        SourceFormat::PowerWorldBinary => Origin::BinaryFile {
1642            path: String::new(),
1643            format: net.source_format.name().to_owned(),
1644            hash: None,
1645            decoded_sections: Vec::new(),
1646        },
1647        other => Origin::File {
1648            path: String::new(),
1649            format: other.name().to_owned(),
1650            hash: None,
1651            retained_source: net.source.is_some(),
1652        },
1653    }
1654}
1655
1656fn balanced_sources(net: &BalancedNetwork) -> Vec<SourceDescriptor> {
1657    let Some(kind) = balanced_source_kind(net.source_format) else {
1658        return Vec::new();
1659    };
1660    vec![SourceDescriptor {
1661        id: "src0".to_owned(),
1662        kind: kind.to_owned(),
1663        path: None,
1664        format: Some(net.source_format.name().to_owned()),
1665        hash: None,
1666    }]
1667}
1668
1669fn balanced_source_kind(f: SourceFormat) -> Option<&'static str> {
1670    match f {
1671        SourceFormat::InMemory | SourceFormat::Normalized => None,
1672        SourceFormat::Gridfm | SourceFormat::PypsaCsv => Some("folder"),
1673        SourceFormat::PowerWorldBinary => Some("binary_file"),
1674        _ => Some("file"),
1675    }
1676}
1677
1678fn balanced_summary(net: &BalancedNetwork) -> ObjectSummary {
1679    let mut elements = BTreeMap::new();
1680    elements.insert("buses".to_owned(), net.buses.len() as u64);
1681    elements.insert("loads".to_owned(), net.loads.len() as u64);
1682    elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1683    elements.insert("branches".to_owned(), net.branches.len() as u64);
1684    elements.insert("generators".to_owned(), net.generators.len() as u64);
1685    elements.insert("storage".to_owned(), net.storage.len() as u64);
1686    elements.insert("hvdc".to_owned(), net.hvdc.len() as u64);
1687    elements.insert(
1688        "transformers_3w".to_owned(),
1689        net.transformers_3w.len() as u64,
1690    );
1691
1692    let reference_buses: Vec<String> = net
1693        .buses
1694        .iter()
1695        .filter(|b| b.kind == powerio::BusType::Ref)
1696        .map(|b| b.id.0.to_string())
1697        .collect();
1698
1699    ObjectSummary {
1700        elements,
1701        topology: Some(ObjectTopology {
1702            connected_components: None,
1703            reference_buses,
1704        }),
1705        units: Some(ObjectUnits {
1706            power: Some("MW/MVAr".to_owned()),
1707            angle: Some("degrees".to_owned()),
1708            base_mva: Some(net.base_mva),
1709        }),
1710    }
1711}
1712
1713fn balanced_source_maps(net: &BalancedNetwork, source_id: Option<&str>) -> Vec<SourceMapEntry> {
1714    let Some(source_id) = source_id else {
1715        return Vec::new();
1716    };
1717    let mut entries = Vec::new();
1718    push_balanced_network_maps(&mut entries, source_id, net.source_format);
1719    push_balanced_bus_maps(&mut entries, source_id, net.buses.len());
1720    push_balanced_injection_maps(&mut entries, source_id, net);
1721    push_balanced_branch_maps(&mut entries, source_id, net);
1722    push_balanced_generator_maps(&mut entries, source_id, net.generators.len());
1723    entries
1724}
1725
1726fn push_balanced_network_maps(
1727    entries: &mut Vec<SourceMapEntry>,
1728    source_id: &str,
1729    source_format: SourceFormat,
1730) {
1731    push_balanced_map(
1732        entries,
1733        source_id,
1734        "/model/balanced_network/base_mva",
1735        "case",
1736        "base_mva",
1737        MappingKind::Exact,
1738    );
1739    if balanced_has_frequency_source(source_format) {
1740        push_balanced_map(
1741            entries,
1742            source_id,
1743            "/model/balanced_network/base_frequency",
1744            "case",
1745            "base_frequency",
1746            MappingKind::Exact,
1747        );
1748    }
1749}
1750
1751fn push_balanced_bus_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1752    push_balanced_record_maps(
1753        entries,
1754        source_id,
1755        "buses",
1756        len,
1757        "bus",
1758        &[
1759            "id", "kind", "vm", "va", "base_kv", "vmax", "vmin", "area", "zone",
1760        ],
1761        MappingKind::Exact,
1762    );
1763}
1764
1765fn push_balanced_injection_maps(
1766    entries: &mut Vec<SourceMapEntry>,
1767    source_id: &str,
1768    net: &BalancedNetwork,
1769) {
1770    if net.source_format == SourceFormat::Matpower {
1771        push_matpower_injection_maps(entries, source_id, net);
1772    } else {
1773        push_balanced_record_maps(
1774            entries,
1775            source_id,
1776            "loads",
1777            net.loads.len(),
1778            "load",
1779            &["bus", "p", "q", "in_service"],
1780            MappingKind::Exact,
1781        );
1782        push_balanced_record_maps(
1783            entries,
1784            source_id,
1785            "shunts",
1786            net.shunts.len(),
1787            "shunt",
1788            &["bus", "g", "b", "in_service"],
1789            MappingKind::Exact,
1790        );
1791    }
1792}
1793
1794fn push_balanced_branch_maps(
1795    entries: &mut Vec<SourceMapEntry>,
1796    source_id: &str,
1797    net: &BalancedNetwork,
1798) {
1799    for (i, branch) in net.branches.iter().enumerate() {
1800        push_balanced_record_map(
1801            entries,
1802            source_id,
1803            "branches",
1804            i,
1805            "branch",
1806            &[
1807                "from",
1808                "to",
1809                "r",
1810                "x",
1811                "b",
1812                "rate_a",
1813                "rate_b",
1814                "rate_c",
1815                "tap",
1816                "shift",
1817                "in_service",
1818                "angmin",
1819                "angmax",
1820            ],
1821            MappingKind::Exact,
1822        );
1823        if branch.charging.is_some() {
1824            for field in ["g_fr", "b_fr", "g_to", "b_to"] {
1825                push_balanced_map(
1826                    entries,
1827                    source_id,
1828                    &format!("/model/balanced_network/branches/{i}/charging/{field}"),
1829                    "branch",
1830                    field,
1831                    MappingKind::Exact,
1832                );
1833            }
1834        }
1835    }
1836}
1837
1838fn push_balanced_generator_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1839    push_balanced_record_maps(
1840        entries,
1841        source_id,
1842        "generators",
1843        len,
1844        "generator",
1845        &[
1846            "bus",
1847            "pg",
1848            "qg",
1849            "pmax",
1850            "pmin",
1851            "qmax",
1852            "qmin",
1853            "vg",
1854            "mbase",
1855            "in_service",
1856        ],
1857        MappingKind::Exact,
1858    );
1859}
1860
1861fn balanced_has_frequency_source(source_format: SourceFormat) -> bool {
1862    matches!(
1863        source_format,
1864        SourceFormat::Psse | SourceFormat::PandapowerJson
1865    )
1866}
1867
1868fn push_matpower_injection_maps(
1869    entries: &mut Vec<SourceMapEntry>,
1870    source_id: &str,
1871    net: &BalancedNetwork,
1872) {
1873    // MATPOWER folds loads and shunts into the bus record. Keep the source
1874    // field token canonical like the rest of the balanced source maps; the
1875    // record and mapping kind carry the folded-row relationship.
1876    push_balanced_record_maps(
1877        entries,
1878        source_id,
1879        "loads",
1880        net.loads.len(),
1881        "bus",
1882        &["bus", "p", "q", "in_service"],
1883        MappingKind::Split,
1884    );
1885    push_balanced_record_maps(
1886        entries,
1887        source_id,
1888        "shunts",
1889        net.shunts.len(),
1890        "bus",
1891        &["bus", "g", "b", "in_service"],
1892        MappingKind::Split,
1893    );
1894}
1895
1896fn push_balanced_record_maps(
1897    entries: &mut Vec<SourceMapEntry>,
1898    source_id: &str,
1899    collection: &str,
1900    len: usize,
1901    record: &str,
1902    fields: &[&str],
1903    mapping_kind: MappingKind,
1904) {
1905    for i in 0..len {
1906        push_balanced_record_map(
1907            entries,
1908            source_id,
1909            collection,
1910            i,
1911            record,
1912            fields,
1913            mapping_kind,
1914        );
1915    }
1916}
1917
1918fn push_balanced_record_map(
1919    entries: &mut Vec<SourceMapEntry>,
1920    source_id: &str,
1921    collection: &str,
1922    i: usize,
1923    record: &str,
1924    fields: &[&str],
1925    mapping_kind: MappingKind,
1926) {
1927    for &field in fields {
1928        push_balanced_map(
1929            entries,
1930            source_id,
1931            &format!("/model/balanced_network/{collection}/{i}/{field}"),
1932            record,
1933            field,
1934            mapping_kind,
1935        );
1936    }
1937}
1938
1939fn push_balanced_map(
1940    entries: &mut Vec<SourceMapEntry>,
1941    source_id: &str,
1942    element_path: &str,
1943    record: &str,
1944    field: &str,
1945    mapping_kind: MappingKind,
1946) {
1947    entries.push(SourceMapEntry {
1948        element_path: element_path.to_owned(),
1949        source_ref: SourceRef::new(source_id)
1950            .with_record(record)
1951            .with_field(field),
1952        mapping_kind,
1953        confidence: Confidence::High,
1954    });
1955}
1956
1957fn multiconductor_summary(net: &MulticonductorNetwork) -> ObjectSummary {
1958    let mut elements = BTreeMap::new();
1959    elements.insert("buses".to_owned(), net.buses.len() as u64);
1960    elements.insert("linecodes".to_owned(), net.linecodes.len() as u64);
1961    elements.insert("lines".to_owned(), net.lines.len() as u64);
1962    elements.insert("switches".to_owned(), net.switches.len() as u64);
1963    elements.insert("transformers".to_owned(), net.transformers.len() as u64);
1964    elements.insert("loads".to_owned(), net.loads.len() as u64);
1965    elements.insert("generators".to_owned(), net.generators.len() as u64);
1966    elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1967    elements.insert("capacitors".to_owned(), net.capacitors.len() as u64);
1968    elements.insert("voltage_sources".to_owned(), net.sources.len() as u64);
1969
1970    ObjectSummary {
1971        elements,
1972        topology: None,
1973        units: Some(ObjectUnits {
1974            power: Some("W/var".to_owned()),
1975            angle: Some("radians".to_owned()),
1976            base_mva: None,
1977        }),
1978    }
1979}
1980
1981fn multiconductor_sources(net: &MulticonductorNetwork) -> Vec<SourceDescriptor> {
1982    match net.source_format {
1983        Some(sf) => vec![SourceDescriptor {
1984            id: "src0".to_owned(),
1985            kind: "file".to_owned(),
1986            path: None,
1987            format: Some(dist_format_name(sf).to_owned()),
1988            hash: None,
1989        }],
1990        None => Vec::new(),
1991    }
1992}
1993
1994fn dist_format_name(f: DistSourceFormat) -> &'static str {
1995    f.name()
1996}
1997
1998fn multiconductor_origin(net: &MulticonductorNetwork) -> Origin {
1999    match net.source_format {
2000        Some(sf) => Origin::File {
2001            path: String::new(),
2002            format: dist_format_name(sf).to_owned(),
2003            hash: None,
2004            retained_source: net.source.is_some(),
2005        },
2006        None => Origin::InMemory,
2007    }
2008}
2009
2010fn derived_sources(parent: &NetworkPackage) -> Vec<SourceDescriptor> {
2011    if !parent.sources.is_empty() {
2012        return parent.sources.clone();
2013    }
2014    vec![SourceDescriptor {
2015        id: "parent".to_owned(),
2016        kind: "package".to_owned(),
2017        path: None,
2018        format: Some("pio-json".to_owned()),
2019        hash: parent.package_id.clone(),
2020    }]
2021}
2022
2023fn lowered_balanced_source_maps(
2024    input: &MulticonductorNetwork,
2025    balanced: &BalancedNetwork,
2026    source_id: Option<&str>,
2027) -> Vec<SourceMapEntry> {
2028    let Some(source_id) = source_id else {
2029        return Vec::new();
2030    };
2031    let mut entries = Vec::new();
2032    push_lowered_bus_maps(&mut entries, source_id, input);
2033    push_lowered_branch_maps(&mut entries, source_id, input, balanced);
2034    push_lowered_load_maps(&mut entries, source_id, input, balanced);
2035    push_lowered_shunt_maps(&mut entries, source_id, input, balanced);
2036    push_lowered_generator_maps(&mut entries, source_id, input, balanced);
2037    entries
2038}
2039
2040fn push_lowered_bus_maps(
2041    entries: &mut Vec<SourceMapEntry>,
2042    source_id: &str,
2043    input: &MulticonductorNetwork,
2044) {
2045    for (idx, bus) in input.buses.iter().enumerate() {
2046        for (field, mapping_kind) in [
2047            ("id", MappingKind::Synthetic),
2048            ("kind", MappingKind::Lowered),
2049            ("vm", MappingKind::ConvertedUnits),
2050            ("va", MappingKind::ConvertedUnits),
2051            ("base_kv", MappingKind::ConvertedUnits),
2052            ("area", MappingKind::Defaulted),
2053            ("zone", MappingKind::Defaulted),
2054            ("name", MappingKind::Lowered),
2055        ] {
2056            push_lowered_map(
2057                entries,
2058                source_id,
2059                &format!("/model/balanced_network/buses/{idx}/{field}"),
2060                "multiconductor_bus",
2061                field,
2062                mapping_kind,
2063            );
2064        }
2065        for field in ["vmin", "vmax"] {
2066            let mapping_kind = if bus.v_min.is_some() && bus.v_max.is_some() {
2067                MappingKind::ConvertedUnits
2068            } else {
2069                MappingKind::Defaulted
2070            };
2071            push_lowered_map(
2072                entries,
2073                source_id,
2074                &format!("/model/balanced_network/buses/{idx}/{field}"),
2075                "multiconductor_bus",
2076                field,
2077                mapping_kind,
2078            );
2079        }
2080    }
2081}
2082
2083fn push_lowered_branch_maps(
2084    entries: &mut Vec<SourceMapEntry>,
2085    source_id: &str,
2086    input: &MulticonductorNetwork,
2087    balanced: &BalancedNetwork,
2088) {
2089    for (idx, branch) in balanced.branches.iter().enumerate() {
2090        let record = "multiconductor_line";
2091        for (field, mapping_kind) in [
2092            ("from", MappingKind::Lowered),
2093            ("to", MappingKind::Lowered),
2094            ("r", MappingKind::ConvertedUnits),
2095            ("x", MappingKind::ConvertedUnits),
2096            ("b", MappingKind::ConvertedUnits),
2097            ("in_service", MappingKind::Lowered),
2098            ("tap", MappingKind::Defaulted),
2099            ("shift", MappingKind::Defaulted),
2100            ("angmin", MappingKind::Defaulted),
2101            ("angmax", MappingKind::Defaulted),
2102        ] {
2103            push_lowered_map(
2104                entries,
2105                source_id,
2106                &format!("/model/balanced_network/branches/{idx}/{field}"),
2107                record,
2108                field,
2109                mapping_kind,
2110            );
2111        }
2112        let has_rating = input
2113            .lines
2114            .get(idx)
2115            .and_then(|line| input.linecode(&line.linecode))
2116            .is_some_and(|code| code.i_max.is_some() || code.s_max.is_some());
2117        let rate_kind = if has_rating {
2118            MappingKind::ConvertedUnits
2119        } else {
2120            MappingKind::Defaulted
2121        };
2122        for field in ["rate_a", "rate_b", "rate_c"] {
2123            push_lowered_map(
2124                entries,
2125                source_id,
2126                &format!("/model/balanced_network/branches/{idx}/{field}"),
2127                record,
2128                field,
2129                rate_kind,
2130            );
2131        }
2132        if branch.charging.is_some() {
2133            for field in ["g_fr", "b_fr", "g_to", "b_to"] {
2134                push_lowered_map(
2135                    entries,
2136                    source_id,
2137                    &format!("/model/balanced_network/branches/{idx}/charging/{field}"),
2138                    record,
2139                    field,
2140                    MappingKind::ConvertedUnits,
2141                );
2142            }
2143        }
2144    }
2145}
2146
2147fn push_lowered_load_maps(
2148    entries: &mut Vec<SourceMapEntry>,
2149    source_id: &str,
2150    input: &MulticonductorNetwork,
2151    balanced: &BalancedNetwork,
2152) {
2153    for idx in 0..balanced.loads.len().min(input.loads.len()) {
2154        for (field, mapping_kind) in [
2155            ("bus", MappingKind::Lowered),
2156            ("p", MappingKind::Aggregated),
2157            ("q", MappingKind::Aggregated),
2158            ("in_service", MappingKind::Lowered),
2159        ] {
2160            push_lowered_map(
2161                entries,
2162                source_id,
2163                &format!("/model/balanced_network/loads/{idx}/{field}"),
2164                "multiconductor_load",
2165                field,
2166                mapping_kind,
2167            );
2168        }
2169    }
2170}
2171
2172fn push_lowered_shunt_maps(
2173    entries: &mut Vec<SourceMapEntry>,
2174    source_id: &str,
2175    input: &MulticonductorNetwork,
2176    balanced: &BalancedNetwork,
2177) {
2178    for idx in 0..balanced.shunts.len().min(input.shunts.len()) {
2179        for (field, mapping_kind) in [
2180            ("bus", MappingKind::Lowered),
2181            ("g", MappingKind::Aggregated),
2182            ("b", MappingKind::Aggregated),
2183            ("in_service", MappingKind::Lowered),
2184        ] {
2185            push_lowered_map(
2186                entries,
2187                source_id,
2188                &format!("/model/balanced_network/shunts/{idx}/{field}"),
2189                "multiconductor_shunt",
2190                field,
2191                mapping_kind,
2192            );
2193        }
2194    }
2195}
2196
2197fn push_lowered_generator_maps(
2198    entries: &mut Vec<SourceMapEntry>,
2199    source_id: &str,
2200    input: &MulticonductorNetwork,
2201    balanced: &BalancedNetwork,
2202) {
2203    for idx in 0..balanced.generators.len().min(input.generators.len()) {
2204        let generator = &input.generators[idx];
2205        for (field, mapping_kind) in [
2206            ("bus", MappingKind::Lowered),
2207            ("pg", MappingKind::Aggregated),
2208            ("qg", MappingKind::Aggregated),
2209            ("vg", MappingKind::Defaulted),
2210            ("mbase", MappingKind::Synthetic),
2211            ("in_service", MappingKind::Lowered),
2212        ] {
2213            push_lowered_map(
2214                entries,
2215                source_id,
2216                &format!("/model/balanced_network/generators/{idx}/{field}"),
2217                "multiconductor_generator",
2218                field,
2219                mapping_kind,
2220            );
2221        }
2222        for (field, present) in [
2223            ("pmin", generator.p_min.is_some()),
2224            ("pmax", generator.p_max.is_some()),
2225            ("qmin", generator.q_min.is_some()),
2226            ("qmax", generator.q_max.is_some()),
2227        ] {
2228            push_lowered_map(
2229                entries,
2230                source_id,
2231                &format!("/model/balanced_network/generators/{idx}/{field}"),
2232                "multiconductor_generator",
2233                field,
2234                if present {
2235                    MappingKind::Aggregated
2236                } else {
2237                    MappingKind::Defaulted
2238                },
2239            );
2240        }
2241    }
2242}
2243
2244fn push_lowered_map(
2245    entries: &mut Vec<SourceMapEntry>,
2246    source_id: &str,
2247    element_path: &str,
2248    record: &str,
2249    field: &str,
2250    mapping_kind: MappingKind,
2251) {
2252    entries.push(SourceMapEntry {
2253        element_path: element_path.to_owned(),
2254        source_ref: SourceRef::new(source_id)
2255            .with_record(record)
2256            .with_field(field),
2257        mapping_kind,
2258        confidence: Confidence::High,
2259    });
2260}
2261
2262/// Lift the `defaulted` map into source-map entries with `mapping_kind =
2263/// defaulted`. Each key is `"class.name"`; each value is the list of fields the
2264/// reader materialized from a format default. The element path is a best-effort
2265/// locator (a precise JSON pointer into the payload arrays is future work).
2266fn multiconductor_source_maps(
2267    net: &MulticonductorNetwork,
2268    source_id: Option<&str>,
2269) -> Vec<SourceMapEntry> {
2270    let Some(source_id) = source_id else {
2271        return Vec::new();
2272    };
2273    let mut entries = Vec::new();
2274    for (element, fields) in &net.defaulted {
2275        for field in fields {
2276            entries.push(SourceMapEntry {
2277                element_path: format!("/model/multiconductor_network/{element}#{field}"),
2278                source_ref: SourceRef::new(source_id).with_field((*field).to_owned()),
2279                mapping_kind: MappingKind::Defaulted,
2280                confidence: Confidence::High,
2281            });
2282        }
2283    }
2284    entries
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    #[test]
2290    fn schema_lineage_parses_semver_suffixes() {
2291        assert_eq!(super::schema_lineage("1.2.3"), Some((1, 2)));
2292        assert_eq!(super::schema_lineage("1.0.0-rc.1"), Some((1, 0)));
2293        // A hyphen inside build metadata is legal semver; splitting on `-`
2294        // first used to cut inside the build tag and reject the version.
2295        assert_eq!(super::schema_lineage("1.0.0+build-x"), Some((1, 0)));
2296        assert_eq!(super::schema_lineage("0.2.0+2026-07-21"), Some((0, 2)));
2297        assert_eq!(super::schema_lineage("1.0.0-rc-1+b-2"), Some((1, 0)));
2298        assert_eq!(super::schema_lineage("1.0"), None);
2299        assert_eq!(super::schema_lineage("1.0.0-"), None);
2300        assert_eq!(super::schema_lineage("01.0.0"), None);
2301    }
2302
2303    #[test]
2304    fn version_gate_is_exact_minor_while_major_is_zero() {
2305        use super::NetworkPackage;
2306        assert!(NetworkPackage::supports_schema_version("0.2.0"));
2307        assert!(NetworkPackage::supports_schema_version("0.2.7"));
2308        assert!(!NetworkPackage::supports_schema_version("0.1.1"));
2309        assert!(!NetworkPackage::supports_schema_version("0.3.0"));
2310        assert!(!NetworkPackage::supports_schema_version("1.0.0"));
2311        assert!(!NetworkPackage::supports_schema_version("garbage"));
2312    }
2313
2314    #[test]
2315    fn envelope_shaped_rejection_names_the_format() {
2316        // Classifier-recognized envelope markers with a missing required
2317        // field: the failure must say what the document failed to be.
2318        let err = super::NetworkPackage::from_json(
2319            r#"{"model_kind":"balanced","model":{"kind":"balanced"}}"#,
2320        )
2321        .unwrap_err();
2322        assert!(err.to_string().contains(".pio.json"), "got: {err}");
2323    }
2324}