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