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