Skip to main content

powerio_pkg/
package.rs

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