Skip to main content

powerio_tnd/formats/sincal/multiconductor/
public.rs

1//! Explicit family selection and source retention at the public reader boundary.
2
3use crate::formats::sincal::storage::{AcquiredProject, DatabaseSnapshot, TableRecords};
4use powerio_core::{Diagnostic, Error, FormatId, PioModule, Source};
5
6use powerio_dist::{DistSourceFormat, MulticonductorNetwork, diagnostics::codes};
7
8/// Selection for the conductor-resolved SINCAL reader. No external tool runs
9/// during parsing. Native SQLite/archive and explicitly acquired Access tables
10/// are distinct input paths; both retain the original primary source bytes.
11#[derive(Clone, Debug, Default, PartialEq)]
12#[non_exhaustive]
13pub struct SincalReadOptions {
14    /// Native variant ID. Omit only when the source has one selectable variant.
15    pub variant: Option<i64>,
16    /// Explicit daily-profile snapshot in hours; no implicit midnight selection.
17    pub snapshot_hours: Option<f64>,
18    /// Relative source companion containing the output of import_access.py.
19    /// The primary source must be the original MDB whose length/hash it names.
20    /// Memory sources supply this companion using Source::with_named_buffer;
21    /// file sources acquire it beneath their configured acquisition root.
22    pub acquired_tables: Option<String>,
23    /// Experimental schema-11.5 compatibility: assume NULL Flag_LfLimit,
24    /// Flag_LfCtrl, Flag_Qctrl, Flag_Macro and Kr mean inactive (zero).
25    /// Active values and missing columns still reject. Applied assumptions emit
26    /// warnings and survive IR serialization in network extras. Default: false.
27    pub assume_inactive_source_controls: bool,
28}
29
30fn failure(error: impl std::fmt::Display, source: &Source) -> Error {
31    Error::new(&codes::PARSE_SINCAL_MULTICONDUCTOR, error.to_string()).with_source(source.clone())
32}
33
34/// Parse a declared conductor-resolved SINCAL profile. Balanced-looking powers
35/// do not change the network family, and unsupported modes reject atomically.
36///
37/// # Errors
38/// Unverified input/schema, unsupported electrical modes, missing snapshot or
39/// acquisition companion, mismatched MDB identity, or inconsistent values.
40pub fn parse_sincal(
41    source: Source,
42    options: &SincalReadOptions,
43) -> Result<PioModule<MulticonductorNetwork>, Error> {
44    if source
45        .format()
46        .is_some_and(|f| !is_multiconductor_token(f.as_str()))
47    {
48        return Err(failure(
49            "SINCAL multiconductor reader conflicts with the declared source format",
50            &source,
51        ));
52    }
53    let source = source.with_format(FormatId::new("sincal-multiconductor")?);
54    let (snapshot, retained) = if let Some(name) = &options.acquired_tables {
55        let primary = source.primary_buffer().map_err(|e| failure(e, &source))?;
56        let records = source
57            .referenced_buffer(&primary, name)
58            .map_err(|e| failure(e, &source))?;
59        let tables = TableRecords::decode(records.bytes()).map_err(|e| failure(e, &source))?;
60        tables
61            .verify_source(primary.bytes())
62            .map_err(|e| failure(e, &source))?;
63        let snapshot = DatabaseSnapshot::decode_records(records.bytes(), options.variant)
64            .map_err(|e| failure(e, &source))?;
65        (snapshot, source)
66    } else {
67        if source
68            .primary_buffer()
69            .map_err(|e| failure(e, &source))?
70            .bytes()
71            .starts_with(b"\0\x01\0\0Standard Jet DB\0")
72        {
73            return Err(failure(
74                "Access MDB parsing requires an explicitly supplied acquired_tables companion from import_access.py; the library does not run external tools",
75                &source,
76            ));
77        }
78        let acquired = AcquiredProject::read(&source).map_err(|e| failure(e, &source))?;
79        let retained = acquired
80            .source
81            .with_format(FormatId::new("sincal-multiconductor")?);
82        let snapshot = DatabaseSnapshot::decode(acquired.database.bytes(), options.variant)
83            .map_err(|e| failure(e, &retained))?;
84        (snapshot, retained)
85    };
86    let geometry = snapshot
87        .drawing_geometry()
88        .map_err(|e| failure(e, &retained))?;
89    let inventory = source_inventory(&snapshot).map_err(|e| failure(e, &retained))?;
90    let mut database = super::schema::NativeDatabase::from_snapshot(snapshot)
91        .map_err(|e| failure(e, &retained))?;
92    database.assume_inactive_source_controls = options.assume_inactive_source_controls;
93    let result = match options.snapshot_hours {
94        Some(hours) => database.network_at(hours),
95        None => database.network(),
96    };
97    let mut network = result.map_err(|e| failure(e, &retained))?;
98    *network.source_format_mut() = Some(DistSourceFormat::Sincal);
99    let mut diagnostics = vec![Diagnostic::of(
100        &codes::READ_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_ONLY,
101        "Only the selected conductor-resolved load-flow profile is typed. Fault, dynamic, protection, untyped graphic fields, results and other native project data remain in retained source. An acquired-table companion is caller-supplied input, not a native export or independent attestation.",
102    )];
103    diagnostics.extend(inventory);
104    let (points, routes) = super::geometry::apply(&mut network, &geometry);
105    if !geometry.points.is_empty() || !geometry.routes.is_empty() || !geometry.findings.is_empty() {
106        let mut report = geometry.diagnostic(&codes::READ_SINCAL_GEOMETRY)?;
107        report.insert_detail("mapped_points", serde_json::json!(points))?;
108        report.insert_detail("mapped_routes", serde_json::json!(routes))?;
109        geometry.annotate_retention(&mut diagnostics, points, routes)?;
110        diagnostics.push(report);
111    }
112    add_provenance(&mut network, database.version, &mut diagnostics)?;
113    let mut module = PioModule::parsed(network, retained, diagnostics)?;
114    geometry.retain_view(&mut module)?;
115    Ok(module)
116}
117
118fn source_inventory(
119    snapshot: &DatabaseSnapshot,
120) -> crate::formats::sincal::storage::Result<Vec<Diagnostic>> {
121    snapshot.retention_diagnostics(
122        &codes::READ_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_ONLY,
123        &[
124            "Version",
125            "Variant",
126            "Node",
127            "Element",
128            "Terminal",
129            "VoltageLevel",
130            "CalcParameter",
131            "Line",
132            "Load",
133            "Infeeder",
134            "TwoWindingTransformer",
135            "DCInfeeder",
136            "OpSer",
137            "OpSerVal",
138            "ShuntCondensator",
139            "LineSeg",
140            "CouplingData",
141            "CoupledLine",
142            "NeutralPointImp",
143            "ShuntImpedance",
144            "ShuntReactor",
145            "ThreeWindingTransformer",
146            "TransformerTap",
147        ],
148        &[
149            (
150                "Infeeder",
151                &[
152                    "Flag_Har",
153                    "HarImp_ID",
154                    "HarVolt_ID",
155                    "HarCur_ID",
156                    "Flag_Reliability",
157                    "SupplyType_ID",
158                ],
159            ),
160            ("Line", &["Flag_Har", "Flag_Reliability"]),
161            ("TwoWindingTransformer", &["Flag_Har", "Flag_Reliability"]),
162        ],
163    )
164}
165
166fn add_provenance(
167    network: &mut MulticonductorNetwork,
168    version: f64,
169    diagnostics: &mut Vec<Diagnostic>,
170) -> Result<(), Error> {
171    let assumptions: std::collections::BTreeMap<_, Vec<_>> = network
172        .defaulted()
173        .iter()
174        .filter(|(component, _)| component.starts_with("Infeeder."))
175        .filter_map(|(component, fields)| {
176            let controls: Vec<_> = fields
177                .iter()
178                .copied()
179                .filter(|field| {
180                    matches!(
181                        *field,
182                        "Flag_LfLimit" | "Flag_LfCtrl" | "Flag_Qctrl" | "Flag_Macro" | "Kr"
183                    )
184                })
185                .collect();
186            (!controls.is_empty()).then(|| (component.clone(), controls))
187        })
188        .collect();
189    if let Some(nodes) = network
190        .extras()
191        .get("sincal_unconnected_nodes")
192        .and_then(|v| v.as_array())
193    {
194        diagnostics.push(Diagnostic::of(&codes::READ_SINCAL_UNCONNECTED_NODES,
195            format!("{} native nodes have no declared equipment conductors; preserved as sincal_unconnected_nodes in extras, without inventing electrical terminals", nodes.len())));
196    }
197    for (component, fields) in &assumptions {
198        diagnostics.push(Diagnostic::of(
199            &codes::READ_SINCAL_ASSUMED_INACTIVE_SOURCE_CONTROLS,
200            format!("Experimental compatibility: {component} NULL fields {} assumed zero/inactive; native SINCAL behavior is unverified", fields.join(", ")),
201        ).with_details(serde_json::json!({"component":component,"fields":fields,"source_value":null,"assumed_value":0,"native_semantics_verified":false}).as_object().unwrap().clone())?);
202    }
203    if !assumptions.is_empty() {
204        network.extras_mut().insert("sincal_compatibility_assumptions".into(),
205            serde_json::json!({"policy": "schema_11_5_null_source_controls_inactive", "assumed_value": 0, "components": &assumptions}));
206    }
207    let defaults: std::collections::BTreeMap<_, _> = network
208        .defaulted()
209        .iter()
210        .filter_map(|(component, fields)| {
211            let fields: Vec<_> = fields
212                .iter()
213                .copied()
214                .filter(|field| {
215                    !assumptions
216                        .get(component)
217                        .is_some_and(|assumed| assumed.contains(field))
218                })
219                .collect();
220            (!fields.is_empty()).then(|| (component.clone(), fields))
221        })
222        .collect();
223    if !defaults.is_empty() {
224        let detail = serde_json::json!(defaults);
225        network
226            .extras_mut()
227            .insert("sincal_defaulted_fields".into(), detail.clone());
228        let mut diagnostic = Diagnostic::of(
229            &codes::READ_MULTICONDUCTOR_VALUE_DEFAULTED,
230            format!(
231                "Versioned SINCAL field defaults applied to {} records; see structured components",
232                defaults.len()
233            ),
234        );
235        diagnostic.insert_detail("components", detail)?;
236        diagnostic.insert_detail("schema_version", serde_json::json!(version))?;
237        diagnostics.push(diagnostic);
238    }
239    Ok(())
240}
241
242pub(crate) fn is_multiconductor_token(name: &str) -> bool {
243    name.chars()
244        .filter(|c| *c != '-' && *c != '_')
245        .collect::<String>()
246        .eq_ignore_ascii_case("sincalmulticonductor")
247}