1pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
46pub enum Format {
47 Matpower,
48 PsseRaw(psse::raw::Version),
53 Xiidm,
54 Ucte,
55 SurgeJson,
56 Dss,
57 Epc,
58 GoC3,
64}
65
66#[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#[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 network.canonicalize_runtime_identities();
167 network.validate()?;
168 Ok(network)
169}
170
171pub 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
240pub 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
259pub 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
296pub 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}