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