Skip to main content

powerio/
sincal_write.rs

1//! Explicit, source-free experimental SINCAL emission. Electrical validation
2//! stays with the owning network family; only packaging is shared here.
3use std::collections::BTreeMap;
4
5use powerio_core::{
6    ArtifactPath, Destination, Diagnostic, EmitResult, Error, Fidelity, MemoryArtifact, PioModule,
7};
8
9use crate::PioValue;
10
11/// Container for fresh experimental SINCAL input data. The destination filename
12/// does not select a container or change the electrical family.
13#[derive(Clone, Debug, Default)]
14#[non_exhaustive]
15pub enum SincalContainer {
16    /// One schema-14.8 SQLite input database.
17    #[default]
18    Sqlite,
19    /// Deterministic candidate `.sinx` packaging. This does not include an
20    /// undocumented desktop `.sin` file, diagrams, results or an Access database.
21    ///
22    /// Kept for compatibility: callers build this struct variant directly,
23    /// so it cannot gain a field. New code should use
24    /// [`SincalContainer::archive_with`], whose [`ArchiveOptions`] can grow.
25    Archive {
26        /// Portable project name: 1–128 ASCII letters, digits, `_` or `-`.
27        project_name: String,
28    },
29    /// The same `.sinx` packaging as [`SincalContainer::Archive`], with
30    /// packaging options. Build it with [`SincalContainer::archive_with`].
31    #[non_exhaustive]
32    ArchiveWithOptions {
33        /// Portable project name: 1–128 ASCII letters, digits, `_` or `-`.
34        project_name: String,
35        /// Packaging options; [`ArchiveOptions::default`] packages exactly as
36        /// [`SincalContainer::Archive`] does.
37        options: ArchiveOptions,
38    },
39}
40
41impl SincalContainer {
42    /// Candidate `.sinx` packaging with explicit options. With
43    /// `ArchiveOptions::default()` the output is the same as
44    /// [`SincalContainer::Archive`] with the same project name.
45    #[must_use]
46    pub fn archive_with(project_name: impl Into<String>, options: ArchiveOptions) -> Self {
47        Self::ArchiveWithOptions {
48            project_name: project_name.into(),
49            options,
50        }
51    }
52}
53
54/// Options for [`SincalContainer::archive_with`]. It has no fields yet; it is
55/// `non_exhaustive` so packaging options can be added without a break. Start
56/// from [`ArchiveOptions::default`].
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct ArchiveOptions {}
60
61/// Explicit opt-in to the experimental static SINCAL writer. Native SINCAL
62/// open/save/calculation acceptance has not been established.
63///
64/// The module's typed network selects its owning writer. There is no implicit
65/// balancing, conductor expansion, or fallback to the other electrical family.
66#[derive(Clone, Debug, Default)]
67#[non_exhaustive]
68pub struct SincalExperimentalOptions {
69    pub container: SincalContainer,
70    /// Required for a multiconductor network: finite positive line-line volts
71    /// for every typed bus. Eliminated transformer auxiliary buses span two
72    /// levels; their entries may be omitted and are ignored if supplied.
73    /// Must be empty for a balanced network, which already has nominal kV.
74    pub nominal_ll_volts: BTreeMap<String, f64>,
75}
76
77pub(crate) fn emit(
78    module: &PioModule<PioValue>,
79    format: &str,
80    options: &SincalExperimentalOptions,
81    destination: Destination,
82) -> Result<EmitResult, Error> {
83    let selected = crate::resolve_format(format).map(|f| f.token);
84    if !matches!(
85        selected,
86        Some("sincal" | "sincal-balanced" | "sincal-multiconductor")
87    ) {
88        return Err(invalid(
89            "experimental SINCAL options require a SINCAL target",
90        ));
91    }
92    let (database, mut diagnostics, retained_omitted) = match module.value() {
93        PioValue::BalancedNetwork(net) => {
94            if selected == Some("sincal-multiconductor") {
95                return Err(invalid(
96                    "sincal-multiconductor cannot write a BalancedNetwork; use sincal-balanced with experimental options",
97                ));
98            }
99            if !options.nominal_ll_volts.is_empty() {
100                return Err(invalid(
101                    "balanced SINCAL output uses typed bus nominal kV; nominal_ll_volts is only a multiconductor option",
102                ));
103            }
104            let output = powerio_tnd::formats::sincal::__write_sincal_balanced_experimental(net)?;
105            (
106                output.database,
107                output.diagnostics,
108                &powerio_tx::diagnostics::codes::EMIT_SINCAL_RETAINED_SOURCE_OMITTED,
109            )
110        }
111        PioValue::MulticonductorNetwork(net) => {
112            if selected == Some("sincal-balanced") {
113                return Err(invalid(
114                    "sincal-balanced cannot write a MulticonductorNetwork; use sincal-multiconductor with experimental options to retain its conductor model",
115                ));
116            }
117            // Built from Default so a field added in a later tnd patch does
118            // not break this crate's build.
119            let mut tnd_options =
120                powerio_tnd::formats::sincal::ExperimentalMulticonductorOptions::default();
121            tnd_options
122                .nominal_ll_volts
123                .clone_from(&options.nominal_ll_volts);
124            let output = powerio_tnd::formats::sincal::__write_sincal_multiconductor_experimental(
125                net,
126                &tnd_options,
127            )?;
128            (
129                output.database,
130                output.diagnostics,
131                &powerio_dist::diagnostics::codes::EMIT_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_OMITTED,
132            )
133        }
134        value => {
135            return Err(Error::new(
136                &crate::codes::REQUEST_MODULE_WRONG_MODEL_KIND,
137                format!(
138                    "experimental SINCAL output requires a BalancedNetwork or MulticonductorNetwork, received {}",
139                    value.type_name()
140                ),
141            ));
142        }
143    };
144    if module.sources().iter().any(|s| {
145        s.format().is_some_and(|f| {
146            matches!(
147                f.as_str(),
148                "sincal" | "sincal-balanced" | "sincal-multiconductor"
149            )
150        })
151    }) {
152        diagnostics.push(Diagnostic::of(
153            retained_omitted,
154            "Experimental fresh output uses the typed electrical value only. Retained native fault, dynamic, protection, diagram, result and other source-only records are omitted.",
155        ));
156    }
157    let (name, bytes) = match &options.container {
158        SincalContainer::Sqlite => ("case.db", database),
159        SincalContainer::Archive { project_name }
160        | SincalContainer::ArchiveWithOptions { project_name, .. } => (
161            "case.sinx",
162            powerio_tnd::formats::sincal::storage::authoring::candidate_archive(
163                &database,
164                project_name,
165            )
166            .map_err(|e| {
167                Error::new(&crate::codes::EMIT_SINCAL_PACKAGING_FAILED, e.to_string()).with_cause(e)
168            })?,
169        ),
170    };
171    // All electrical checks and complete packaging finish in memory before
172    // committing. Unsupported input cannot create or replace an output file.
173    destination.__commit_artifacts(
174        false,
175        Fidelity::Canonical,
176        vec![MemoryArtifact::new(ArtifactPath::new(name)?, bytes)],
177        diagnostics,
178    )
179}
180
181fn invalid(message: &str) -> Error {
182    Error::new(&crate::codes::REQUEST_EMIT_INVALID_OPTIONS, message)
183}