Skip to main content

surge_io/
lib.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Surge I/O — canonical network interchange APIs.
3//!
4//! The crate root is intentionally small:
5//!
6//! - [`load`] for extension-driven network file loading
7//! - [`save`] for extension-driven network file saving
8//! - [`loads`] / [`dumps`] for in-memory single-document formats
9//!
10//! Multi-profile formats and sidecar artifacts live under their own modules:
11//!
12//! - [`cgmes`] for explicit CGMES load/save/profile generation
13//! - [`psse::raw`], [`psse::rawx`], [`psse::dyr`], [`psse::dyd`], [`psse::sequence`]
14//! - [`geo`] and [`export`] for coordinate and tabular export utilities
15
16pub mod bin;
17pub mod cgmes;
18pub mod comtrade;
19pub mod dss;
20pub mod epc;
21pub mod export;
22pub mod geo;
23pub mod go_c3;
24pub mod iec62325;
25pub mod ieee_cdf;
26pub mod json;
27pub mod matpower;
28pub mod profiles;
29pub mod pscad;
30pub mod psse;
31pub mod saturation_toml;
32pub mod scl;
33pub mod shaft_toml;
34pub mod ucte;
35pub mod xiidm;
36
37mod parse_utils;
38mod union_find;
39
40use std::path::{Path, PathBuf};
41
42use surge_network::{Network, NetworkError};
43
44/// In-memory single-document formats supported by [`loads`] and [`dumps`].
45#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
46pub enum Format {
47    Matpower,
48    /// PSS/E RAW format.
49    ///
50    /// `loads()` auto-detects the RAW version from the document content. The
51    /// stored version is used by `dumps()` / `save()` when serializing.
52    PsseRaw(psse::raw::Version),
53    Xiidm,
54    Ucte,
55    SurgeJson,
56    Dss,
57    Epc,
58    /// GO Competition Challenge 3 JSON format.
59    ///
60    /// `loads()` extracts the static network from the GO C3 problem JSON.
61    /// For full access to time series and reliability data, use the
62    /// [`go_c3`] module directly.
63    GoC3,
64}
65
66/// Errors from [`load`] and [`loads`].
67#[derive(Debug, thiserror::Error)]
68pub enum LoadError {
69    #[error(
70        "unsupported input format: '{0}'. Supported: directory, .m, .raw, .rawx, .cdf, .xiidm/.iidm, .uct/.ucte, .xml/.cim, .zip, .epc, .dss, .surge.json, .surge.json.zst, .json, .json.zst, .surge.bin"
71    )]
72    UnsupportedFormat(String),
73
74    #[error(transparent)]
75    Cgmes(#[from] cgmes::Error),
76    #[error(transparent)]
77    Matpower(#[from] matpower::LoadError),
78    #[error(transparent)]
79    PsseRaw(#[from] psse::raw::LoadError),
80    #[error(transparent)]
81    Rawx(#[from] psse::rawx::LoadError),
82    #[error(transparent)]
83    Cdf(#[from] ieee_cdf::Error),
84    #[error(transparent)]
85    Xiidm(#[from] xiidm::Error),
86    #[error(transparent)]
87    Ucte(#[from] ucte::LoadError),
88    #[error(transparent)]
89    Epc(#[from] epc::LoadError),
90    #[error(transparent)]
91    Dss(#[from] dss::LoadError),
92    #[error(transparent)]
93    Json(#[from] json::Error),
94    #[error(transparent)]
95    Bin(#[from] bin::Error),
96    #[error(transparent)]
97    GoC3(#[from] go_c3::Error),
98    #[error(transparent)]
99    InvalidNetwork(#[from] NetworkError),
100}
101
102/// Errors from [`save`] and [`dumps`].
103#[derive(Debug, thiserror::Error)]
104pub enum SaveError {
105    #[error(
106        "directory target {path} requires an explicit module; use surge_io::cgmes::save for CGMES output"
107    )]
108    DirectoryTarget { path: PathBuf },
109
110    #[error("CGMES output is explicit; use surge_io::cgmes::save for '{path}'")]
111    ExplicitCgmesTarget { path: PathBuf },
112
113    #[error(
114        "unsupported export format: '{0}'. Supported: .m, .raw, .epc, .xiidm/.iidm, .dss, .uct/.ucte, .surge.json, .surge.json.zst, .json, .json.zst, .surge.bin. Use surge_io::cgmes::save for CGMES."
115    )]
116    UnsupportedFormat(String),
117
118    #[error(transparent)]
119    Matpower(#[from] matpower::SaveError),
120    #[error(transparent)]
121    PsseRaw(#[from] psse::raw::SaveError),
122    #[error(transparent)]
123    Xiidm(#[from] xiidm::Error),
124    #[error(transparent)]
125    Json(#[from] json::Error),
126    #[error(transparent)]
127    Bin(#[from] bin::Error),
128    #[error(transparent)]
129    Dss(#[from] dss::SaveError),
130    #[error(transparent)]
131    Epc(#[from] epc::SaveError),
132    #[error(transparent)]
133    Ucte(#[from] ucte::SaveError),
134}
135
136fn lowercase_filename(path: &Path) -> String {
137    path.file_name()
138        .and_then(|value| value.to_str())
139        .unwrap_or("")
140        .to_ascii_lowercase()
141}
142
143fn canonical_format_name(path: &Path) -> String {
144    let filename = lowercase_filename(path);
145    if filename.ends_with(".surge.json.zst") {
146        ".surge.json.zst".to_string()
147    } else if filename.ends_with(".json.zst") {
148        ".json.zst".to_string()
149    } else if filename.ends_with(".surge.json") {
150        ".surge.json".to_string()
151    } else if filename.ends_with(".json") {
152        ".json".to_string()
153    } else if filename.ends_with(".surge.bin") {
154        ".surge.bin".to_string()
155    } else {
156        path.extension()
157            .and_then(|e| e.to_str())
158            .map(|e| format!(".{}", e.to_ascii_lowercase()))
159            .unwrap_or_default()
160    }
161}
162
163fn finalize_loaded_network(mut network: Network) -> Result<Network, LoadError> {
164    // Canonicalize runtime-facing identities once at the I/O boundary so all
165    // downstream solver/study APIs see a stable network contract.
166    network.canonicalize_runtime_identities();
167    network.validate()?;
168    Ok(network)
169}
170
171/// Load a network file, auto-detecting the format from the file extension.
172///
173/// Supported inputs:
174/// - Directory of CGMES `.xml` files (ENTSO-E multi-profile bundle)
175/// - `.m` — MATPOWER
176/// - `.raw` — PSS/E RAW
177/// - `.rawx` — PSS/E RAWX (JSON)
178/// - `.cdf` — IEEE CDF
179/// - `.xiidm`, `.iidm` — PowSyBl XIIDM
180/// - `.uct`, `.ucte` — UCTE-DEF
181/// - `.xml`, `.cim` — CGMES/CIM
182/// - `.zip` — CGMES multi-profile bundle packaged as a zip archive
183/// - `.epc` — GE PSLF EPC
184/// - `.dss` — OpenDSS
185/// - `.surge.json`, `.json` — Surge JSON
186/// - `.surge.json.zst`, `.json.zst` — zstd-compressed Surge JSON
187/// - `.surge.bin` — Surge binary
188///
189/// # Example
190///
191/// ```no_run
192/// use surge_io::load;
193///
194/// let net = load("examples/cases/ieee118/case118.surge.json.zst").unwrap();
195/// println!("{} buses, {} branches", net.buses.len(), net.branches.len());
196/// ```
197pub fn load(path: impl AsRef<Path>) -> Result<Network, LoadError> {
198    let path = path.as_ref();
199    let network = if path.is_dir() {
200        Ok(cgmes::load(path)?)
201    } else {
202        let format_name = canonical_format_name(path);
203
204        tracing::info!(
205            path = %path.display(),
206            format = format_name.as_str(),
207            "parsing case file"
208        );
209
210        match format_name.as_str() {
211            ".m" => Ok(matpower::load(path)?),
212            ".raw" => Ok(psse::raw::load(path)?),
213            ".rawx" => Ok(psse::rawx::load(path)?),
214            ".cdf" => Ok(ieee_cdf::load(path)?),
215            ".xiidm" | ".iidm" => Ok(xiidm::load(path)?),
216            ".uct" | ".ucte" => Ok(ucte::load(path)?),
217            ".xml" | ".cim" | ".zip" => Ok(cgmes::load(path)?),
218            ".epc" => Ok(epc::load(path)?),
219            ".dss" => Ok(dss::load(path)?),
220            ".surge.json" | ".surge.json.zst" | ".json" | ".json.zst" => Ok(json::load(path)?),
221            ".surge.bin" => Ok(bin::load(path)?),
222            _ => Err(LoadError::UnsupportedFormat(format_name.clone())),
223        }
224    };
225
226    let network = network.and_then(finalize_loaded_network);
227
228    if let Ok(ref net) = network {
229        tracing::info!(
230            buses = net.n_buses(),
231            branches = net.branches.len(),
232            generators = net.generators.len(),
233            "case file parsed"
234        );
235    }
236
237    network
238}
239
240/// Parse an in-memory network document.
241pub fn loads(content: &str, format: Format) -> Result<Network, LoadError> {
242    match format {
243        Format::Matpower => Ok(matpower::loads(content)?),
244        Format::PsseRaw(_) => Ok(psse::raw::loads(content)?),
245        Format::Xiidm => Ok(xiidm::loads(content)?),
246        Format::Ucte => Ok(ucte::loads(content)?),
247        Format::SurgeJson => Ok(json::loads(content)?),
248        Format::Dss => Ok(dss::loads(content)?),
249        Format::Epc => Ok(epc::loads(content)?),
250        Format::GoC3 => {
251            let problem = go_c3::load_problem_str(content)?;
252            let (net, _ctx) = go_c3::to_network(&problem)?;
253            Ok(net)
254        }
255    }
256    .and_then(finalize_loaded_network)
257}
258
259/// Save a network to a file, auto-detecting the format from the file extension.
260///
261/// Supported outputs: `.m`, `.raw`, `.xiidm`, `.iidm`, `.dss`, `.epc`,
262/// `.uct`, `.ucte`, `.surge.json`, `.surge.json.zst`, `.json`, `.json.zst`,
263/// `.surge.bin`.
264///
265/// CGMES output is explicit and directory-based. Use [`cgmes::save`] instead.
266pub fn save(network: &Network, path: impl AsRef<Path>) -> Result<(), SaveError> {
267    let path = path.as_ref();
268    if path.is_dir() {
269        return Err(SaveError::DirectoryTarget {
270            path: path.to_path_buf(),
271        });
272    }
273
274    let format_name = canonical_format_name(path);
275
276    match format_name.as_str() {
277        ".m" => matpower::save(network, path)?,
278        ".raw" => psse::raw::save(network, path, psse::raw::Version::V33)?,
279        ".xiidm" | ".iidm" => xiidm::save(network, path)?,
280        ".surge.json" | ".surge.json.zst" | ".json" | ".json.zst" => json::save(network, path)?,
281        ".surge.bin" => bin::save(network, path)?,
282        ".dss" => dss::save(network, path)?,
283        ".epc" => epc::save(network, path)?,
284        ".uct" | ".ucte" => ucte::save(network, path)?,
285        ".xml" | ".cim" | ".zip" => {
286            return Err(SaveError::ExplicitCgmesTarget {
287                path: path.to_path_buf(),
288            });
289        }
290        _ => return Err(SaveError::UnsupportedFormat(format_name)),
291    }
292
293    Ok(())
294}
295
296/// Serialize a network into an in-memory document.
297pub fn dumps(network: &Network, format: Format) -> Result<String, SaveError> {
298    match format {
299        Format::Matpower => Ok(matpower::dumps(network)?),
300        Format::PsseRaw(version) => Ok(psse::raw::dumps(network, version)?),
301        Format::Xiidm => Ok(xiidm::dumps(network)?),
302        Format::Ucte => Ok(ucte::dumps(network)?),
303        Format::SurgeJson => Ok(json::dumps(network)?),
304        Format::Dss => Ok(dss::dumps(network)?),
305        Format::Epc => Ok(epc::dumps(network)?),
306        Format::GoC3 => Err(SaveError::UnsupportedFormat(
307            "GO C3 network-only export is not supported; use go_c3::save_solution() for solution output".to_string(),
308        )),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::io::Write as _;
316    use surge_network::network::{Branch, Bus, BusType, Generator, Load};
317
318    fn mini_network() -> Network {
319        let mut net = Network::new("case_mini");
320        net.base_mva = 100.0;
321
322        let mut slack = Bus::new(1, BusType::Slack, 345.0);
323        slack.voltage_magnitude_pu = 1.04;
324        net.buses.push(slack);
325
326        let pq = Bus::new(2, BusType::PQ, 345.0);
327        net.buses.push(pq);
328        net.loads.push(Load::new(2, 100.0, 35.0));
329
330        net.generators.push(Generator::new(1, 80.0, 1.04));
331        net.branches.push(Branch::new_line(1, 2, 0.02, 0.06, 0.03));
332        net
333    }
334
335    fn write_zip(entries: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
336        let dir = tempfile::tempdir().unwrap();
337        let zip_path = dir.path().join("bundle.zip");
338        let file = std::fs::File::create(&zip_path).unwrap();
339        let mut zip = zip::ZipWriter::new(file);
340        let options = zip::write::SimpleFileOptions::default();
341        for (name, contents) in entries {
342            zip.start_file(name, options).unwrap();
343            zip.write_all(contents.as_bytes()).unwrap();
344        }
345        zip.finish().unwrap();
346        (dir, zip_path)
347    }
348
349    #[test]
350    fn test_load_matpower_extension_routes() {
351        let result = load("nonexistent.m");
352        assert!(result.is_err());
353        let msg = result.unwrap_err().to_string();
354        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
355    }
356
357    #[test]
358    fn test_load_psse_extension_routes() {
359        let result = load("nonexistent.raw");
360        assert!(result.is_err());
361        let msg = result.unwrap_err().to_string();
362        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
363    }
364
365    #[test]
366    fn test_load_xiidm_extension_routes() {
367        let result = load("nonexistent.xiidm");
368        assert!(result.is_err());
369        let msg = result.unwrap_err().to_string();
370        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
371    }
372
373    #[test]
374    fn test_load_ucte_extension_routes() {
375        let result = load("nonexistent.ucte");
376        assert!(result.is_err());
377        let msg = result.unwrap_err().to_string();
378        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
379    }
380
381    #[test]
382    fn test_load_json_extension_routes() {
383        let result = load("nonexistent.surge.json");
384        assert!(result.is_err());
385        let msg = result.unwrap_err().to_string();
386        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
387    }
388
389    #[test]
390    fn test_load_json_zst_extension_routes() {
391        let result = load("nonexistent.surge.json.zst");
392        assert!(result.is_err());
393        let msg = result.unwrap_err().to_string();
394        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
395    }
396
397    #[test]
398    fn test_load_bin_extension_routes() {
399        let result = load("nonexistent.surge.bin");
400        assert!(result.is_err());
401        let msg = result.unwrap_err().to_string();
402        assert!(!msg.contains("unsupported input format"), "Got: {msg}");
403    }
404
405    #[test]
406    fn test_load_unknown_extension_errors() {
407        let result = load("file.xyz");
408        assert!(result.is_err());
409        let msg = result.unwrap_err().to_string();
410        assert!(msg.contains("unsupported input format"), "Got: {msg}");
411    }
412
413    #[test]
414    fn test_load_cgmes_directory() {
415        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
416        let workspace = PathBuf::from(&manifest)
417            .join("../..")
418            .join("tests/data/cgmes/case14");
419        let path = match std::fs::canonicalize(&workspace) {
420            Ok(path) => path,
421            Err(_) => return,
422        };
423
424        let result = load(&path);
425        match result {
426            Ok(net) => assert_eq!(net.n_buses(), 14),
427            Err(err) => panic!("load on CGMES directory failed: {err}"),
428        }
429    }
430
431    #[test]
432    fn test_load_zip_rejects_unsafe_paths() {
433        let (_dir, zip_path) = write_zip(&[("../EQ.xml", "<xml />")]);
434        let err = load(&zip_path).unwrap_err();
435        match err {
436            LoadError::Cgmes(cgmes::Error::InvalidArchiveEntryPath { .. }) => {}
437            other => panic!("expected invalid archive path error, got: {other}"),
438        }
439    }
440
441    #[test]
442    fn test_load_zip_skips_diagramlayout_case_insensitively() {
443        let (_dir, zip_path) = write_zip(&[("profiles/diagramlayout.xml", "<DiagramLayout />")]);
444        let err = load(&zip_path).unwrap_err();
445        match err {
446            LoadError::Cgmes(cgmes::Error::NoProfiles { .. }) => {}
447            other => panic!("expected no CGMES profiles error, got: {other}"),
448        }
449    }
450
451    #[test]
452    fn test_save_m_extension_roundtrip() {
453        let net = mini_network();
454        let tmp = std::env::temp_dir().join("surge_save_test.m");
455        save(&net, &tmp).unwrap();
456        let net2 = load(&tmp).unwrap();
457        assert_eq!(net2.n_buses(), net.n_buses());
458        assert_eq!(net2.n_branches(), net.n_branches());
459        let _ = std::fs::remove_file(&tmp);
460    }
461
462    #[test]
463    fn test_save_raw_extension_roundtrip() {
464        let net = mini_network();
465        let tmp = std::env::temp_dir().join("surge_save_test.raw");
466        save(&net, &tmp).unwrap();
467        let contents = std::fs::read_to_string(&tmp).unwrap();
468        assert!(
469            contents
470                .lines()
471                .next()
472                .unwrap_or_default()
473                .contains("PSS/E 33 Raw Data"),
474            "generic .raw save should emit version 33, got: {}",
475            contents.lines().next().unwrap_or_default()
476        );
477        let net2 = load(&tmp).unwrap();
478        assert_eq!(net2.n_buses(), net.n_buses());
479        let _ = std::fs::remove_file(&tmp);
480    }
481
482    #[test]
483    fn test_save_xiidm_roundtrip() {
484        let net = mini_network();
485        let tmp = std::env::temp_dir().join("surge_save_test.xiidm");
486        save(&net, &tmp).unwrap();
487        let net2 = load(&tmp).unwrap();
488        assert_eq!(net2.n_buses(), net.n_buses());
489        let _ = std::fs::remove_file(&tmp);
490    }
491
492    #[test]
493    fn test_save_json_extension_roundtrip() {
494        let net = mini_network();
495        let tmp = std::env::temp_dir().join("surge_save_test.surge.json");
496        save(&net, &tmp).unwrap();
497        let net2 = load(&tmp).unwrap();
498        assert_eq!(net2.n_buses(), net.n_buses());
499        let _ = std::fs::remove_file(&tmp);
500    }
501
502    #[test]
503    fn test_save_json_zst_extension_roundtrip() {
504        let net = mini_network();
505        let tmp = std::env::temp_dir().join("surge_save_test.surge.json.zst");
506        save(&net, &tmp).unwrap();
507        let net2 = load(&tmp).unwrap();
508        assert_eq!(net2.n_buses(), net.n_buses());
509        let _ = std::fs::remove_file(&tmp);
510    }
511
512    #[test]
513    fn test_save_bin_extension_roundtrip() {
514        let net = mini_network();
515        let tmp = std::env::temp_dir().join("surge_save_test.surge.bin");
516        save(&net, &tmp).unwrap();
517        let net2 = load(&tmp).unwrap();
518        assert_eq!(net2.n_buses(), net.n_buses());
519        let _ = std::fs::remove_file(&tmp);
520    }
521
522    #[test]
523    fn test_save_rejects_cgmes_file_target() {
524        let net = mini_network();
525        let tmp = std::env::temp_dir().join("surge_save_test.xml");
526        let result = save(&net, &tmp);
527        assert!(result.is_err());
528        let msg = result.unwrap_err().to_string();
529        assert!(msg.contains("surge_io::cgmes::save"), "Got: {msg}");
530    }
531
532    #[test]
533    fn test_loads_canonicalizes_runtime_ids() {
534        let mut net = mini_network();
535        net.generators[0].id = "  ".to_string();
536        let mut switched_shunt =
537            surge_network::network::SwitchedShunt::capacitor_only(2, 0.1, 2, 1.0);
538        switched_shunt.id = " ".to_string();
539        net.controls.switched_shunts.push(switched_shunt);
540
541        let json = json::dumps(&net).expect("serialize network");
542        let loaded = loads(&json, Format::SurgeJson).expect("loads should canonicalize");
543
544        assert_eq!(loaded.generators[0].id, "gen_1_1");
545        assert_eq!(loaded.controls.switched_shunts[0].id, "switched_shunt_2_1");
546    }
547
548    #[test]
549    fn test_loads_rejects_invalid_area_schedule_contract() {
550        let mut net = mini_network();
551        net.area_schedules
552            .push(surge_network::network::AreaSchedule {
553                number: 1,
554                slack_bus: 999,
555                p_desired_mw: 10.0,
556                p_tolerance_mw: 5.0,
557                name: "bad".to_string(),
558            });
559
560        let json = json::dumps(&net).expect("serialize network");
561        let err = loads(&json, Format::SurgeJson).unwrap_err();
562        assert!(matches!(
563            err,
564            LoadError::InvalidNetwork(NetworkError::InvalidAreaScheduleSlackBus {
565                area: 1,
566                slack_bus: 999
567            })
568        ));
569    }
570}