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