powerio_tnd/formats/sincal/multiconductor/
public.rs1use 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#[derive(Clone, Debug, Default, PartialEq)]
12#[non_exhaustive]
13pub struct SincalReadOptions {
14 pub variant: Option<i64>,
16 pub snapshot_hours: Option<f64>,
18 pub acquired_tables: Option<String>,
23 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
34pub 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}