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