Skip to main content

powerio_tnd/formats/sincal/balanced/
source.rs

1//! Native binary acquisition and module assembly for the explicit profile.
2use crate::formats::sincal::storage::{AcquiredProject, DatabaseSnapshot, TableRecords};
3use powerio_core::{Diagnostic, Error, FormatId, PioModule, Source};
4
5use powerio_tx::BalancedNetwork;
6use powerio_tx::diagnostics::codes;
7use powerio_tx::format::routing::{TransmissionFormat, parse_transmission_format};
8
9/// Explicit variant, snapshot and Access acquisition for the balanced reader.
10/// Acquisition records are caller-supplied; parsing never runs external tools.
11#[derive(Clone, Debug, Default, PartialEq)]
12#[non_exhaustive]
13pub struct SincalBalancedReadOptions {
14    /// Native variant ID; omission requires one selectable variant.
15    pub variant: Option<i64>,
16    /// Daily-profile snapshot in hours, without an implicit midnight default.
17    pub snapshot_hours: Option<f64>,
18    /// Relative companion containing import_access.py records. The primary
19    /// source must be the original MDB matching the records' length and digest.
20    pub acquired_tables: Option<String>,
21}
22
23fn failure(error: impl std::fmt::Display, source: &Source) -> Error {
24    Error::new(&codes::PARSE_SINCAL_MALFORMED, error.to_string()).with_source(source.clone())
25}
26
27pub fn parse(source: Source) -> Result<PioModule<BalancedNetwork>, Error> {
28    parse_with_options(source, &SincalBalancedReadOptions::default())
29}
30
31/// Parse an explicitly declared balanced SINCAL source with native selections.
32///
33/// # Errors
34/// Missing or conflicting family selection, unsupported electrical semantics,
35/// invalid snapshot, or missing/mismatched acquisition records.
36pub fn parse_with_options(
37    source: Source,
38    options: &SincalBalancedReadOptions,
39) -> Result<PioModule<BalancedNetwork>, Error> {
40    let selected = source
41        .format()
42        .and_then(|f| parse_transmission_format(f.as_str()));
43    if selected != Some(TransmissionFormat::SincalBalanced) {
44        return Err(Error::new(&codes::REQUEST_SINCAL_PROFILE_REQUIRED,
45            "SINCAL can contain balanced or multiconductor networks; select 'sincal-balanced' for positive sequence or 'sincal-multiconductor' for conductor-resolved interpretation. There is no balancing fallback.")
46            .with_source(source));
47    }
48    let source = source.with_format(FormatId::new("sincal-balanced")?);
49    let (snapshot, retained) = if let Some(name) = &options.acquired_tables {
50        let primary = source.primary_buffer().map_err(|e| failure(e, &source))?;
51        let records = source
52            .referenced_buffer(&primary, name)
53            .map_err(|e| failure(e, &source))?;
54        TableRecords::decode(records.bytes())
55            .map_err(|e| failure(e, &source))?
56            .verify_source(primary.bytes())
57            .map_err(|e| failure(e, &source))?;
58        let snapshot = DatabaseSnapshot::decode_records(records.bytes(), options.variant)
59            .map_err(|e| failure(e, &source))?;
60        (snapshot, source)
61    } else {
62        if source
63            .primary_buffer()
64            .map_err(|e| failure(e, &source))?
65            .bytes()
66            .starts_with(b"\0\x01\0\0Standard Jet DB\0")
67        {
68            return Err(failure(
69                "Access MDB parsing requires an explicitly supplied acquired_tables companion from import_access.py; the library does not run external tools",
70                &source,
71            ));
72        }
73        let acquired = AcquiredProject::read(&source).map_err(|e| failure(e, &source))?;
74        let retained = acquired
75            .source
76            .with_format(FormatId::new("sincal-balanced")?);
77        let snapshot = DatabaseSnapshot::decode(acquired.database.bytes(), options.variant)
78            .map_err(|e| failure(e, &retained))?;
79        (snapshot, retained)
80    };
81    let geometry = snapshot
82        .drawing_geometry()
83        .map_err(|e| failure(e, &retained))?;
84    let mut network =
85        super::read_balanced_snapshot_at(&snapshot, retained.name(), options.snapshot_hours)
86            .map_err(|e| failure(e, &retained))?;
87    let inventory = retention_inventory(&snapshot, profile_applied(&network))
88        .map_err(|e| failure(e, &retained))?;
89    let mut diagnostics = vec![
90        Diagnostic::of(
91            &codes::READ_SINCAL_CONVERSION_BASE,
92            "The balanced profile maps native powers, voltages and impedances on a 100 MVA conversion base; this is not a native system base.",
93        ),
94        Diagnostic::of(
95            &codes::READ_SINCAL_RETAINED_SOURCE_ONLY,
96            "Fault, dynamic, protection, economic and untyped graphic data, stored calculation results, and native settings outside the selected load-flow snapshot remain only in retained source. They are not part of the typed balanced network. An acquired-table companion is caller-supplied input, not a native export or independent attestation.",
97        ),
98    ];
99    diagnostics.extend(inventory);
100    super::geometry::attach(&mut network, &geometry, &mut diagnostics)?;
101    diagnostics.extend(default_diagnostics(&network)?);
102    if !network.generators().is_empty() {
103        diagnostics.push(Diagnostic::of(&codes::READ_SINCAL_LIMITS_UNSPECIFIED,
104            "Native generator capability limits are disabled in this profile; typed P/Q limits are unbounded. This does not establish OPF capability data."));
105    }
106    let mut module = PioModule::parsed(network, retained, diagnostics)?;
107    geometry.retain_view(&mut module)?;
108    Ok(module)
109}
110
111fn default_diagnostics(
112    network: &powerio_tx::network::BalancedNetwork,
113) -> Result<Vec<Diagnostic>, Error> {
114    let defaults = network
115        .buses()
116        .iter()
117        .filter_map(|bus| {
118            bus.extras
119                .get("sincal_voltage_basis")
120                .map(|value| (&bus.uid, value))
121        })
122        .chain(network.branches().iter().filter_map(|branch| {
123            branch
124                .extras
125                .get("sincal_defaulted_temperature")
126                .map(|value| (&branch.uid, value))
127        }));
128    defaults.map(|(component, value)| {
129        let mut diagnostic = Diagnostic::of(&codes::READ_SINCAL_VALUE_DEFAULTED,
130            format!("Versioned SINCAL field default applied to {component:?}; see structured native field and value"));
131        diagnostic.insert_detail("component", serde_json::json!(component))?;
132        diagnostic.insert_detail("default", value.clone())?;
133        Ok(diagnostic)
134    }).collect()
135}
136
137/// Whether a selected daily snapshot set any load from its profile.
138pub(super) fn profile_applied(network: &BalancedNetwork) -> bool {
139    network
140        .loads()
141        .iter()
142        .any(|load| load.extras.contains_key("sincal_profile"))
143}
144
145/// `OpSer` and `OpSerVal` count as interpreted only when a selected daily
146/// snapshot applied a profile to a load. Otherwise the reader never read
147/// them, and their rows are reported as source-only like any other table.
148pub(super) fn retention_inventory(
149    snapshot: &DatabaseSnapshot,
150    profile_applied: bool,
151) -> crate::formats::sincal::storage::Result<Vec<Diagnostic>> {
152    let profile_tables: &[&str] = if profile_applied {
153        &["OpSer", "OpSerVal"]
154    } else {
155        &[]
156    };
157    let interpreted = [
158        "Version",
159        "Variant",
160        "Node",
161        "Element",
162        "Terminal",
163        "VoltageLevel",
164        "CalcParameter",
165        "Line",
166        "Load",
167        "Infeeder",
168        "TwoWindingTransformer",
169        "DCInfeeder",
170        "ShuntCondensator",
171    ]
172    .iter()
173    .chain(profile_tables)
174    .copied()
175    .collect::<Vec<_>>();
176    snapshot.retention_diagnostics(
177        &codes::READ_SINCAL_RETAINED_SOURCE_ONLY,
178        &interpreted,
179        &[
180            (
181                "Infeeder",
182                &[
183                    "Flag_Har",
184                    "HarImp_ID",
185                    "HarVolt_ID",
186                    "HarCur_ID",
187                    "Flag_Reliability",
188                    "SupplyType_ID",
189                ],
190            ),
191            ("Line", &["Flag_Har", "Flag_Reliability"]),
192            ("TwoWindingTransformer", &["Flag_Har", "Flag_Reliability"]),
193        ],
194    )
195}