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