1use std::collections::HashMap;
10use std::path::Path;
11
12use surge_network::Network;
13use surge_network::network::{Branch, BranchType, Bus, BusType, Generator, Load};
14use thiserror::Error;
15
16#[derive(Error, Debug)]
17pub enum UcteError {
18 #[error("I/O error: {0}")]
19 Io(#[from] std::io::Error),
20 #[error("parse error on line {line}: {message}")]
21 Parse { line: usize, message: String },
22}
23
24fn parse_required_u32(token: Option<&str>, line: usize, field: &str) -> Result<u32, UcteError> {
25 let value = token.ok_or_else(|| UcteError::Parse {
26 line,
27 message: format!("missing required field {field}"),
28 })?;
29 value.parse::<u32>().map_err(|_| UcteError::Parse {
30 line,
31 message: format!("invalid {field}: {value}"),
32 })
33}
34
35fn parse_required_f64(token: Option<&str>, line: usize, field: &str) -> Result<f64, UcteError> {
36 let value = token.ok_or_else(|| UcteError::Parse {
37 line,
38 message: format!("missing required field {field}"),
39 })?;
40 value.parse::<f64>().map_err(|_| UcteError::Parse {
41 line,
42 message: format!("invalid {field}: {value}"),
43 })
44}
45
46fn parse_optional_f64(
47 token: Option<&str>,
48 line: usize,
49 field: &str,
50) -> Result<Option<f64>, UcteError> {
51 match token {
52 Some(value) => value
53 .parse::<f64>()
54 .map(Some)
55 .map_err(|_| UcteError::Parse {
56 line,
57 message: format!("invalid {field}: {value}"),
58 }),
59 None => Ok(None),
60 }
61}
62
63fn parse_required_digit_at(
64 raw: &str,
65 idx: usize,
66 line: usize,
67 field: &str,
68) -> Result<u32, UcteError> {
69 let ch = raw.chars().nth(idx).ok_or_else(|| UcteError::Parse {
70 line,
71 message: format!("missing required field {field}"),
72 })?;
73 ch.to_digit(10).ok_or_else(|| UcteError::Parse {
74 line,
75 message: format!("invalid {field}: {ch}"),
76 })
77}
78
79pub fn parse_file(path: &Path) -> Result<Network, UcteError> {
81 let content = std::fs::read_to_string(path)?;
82 parse_str(&content)
83}
84
85pub fn parse_str(content: &str) -> Result<Network, UcteError> {
87 let mut network = Network::new("ucte_network");
88 let mut node_to_num: HashMap<String, u32> = HashMap::new();
90 let mut next_num: u32 = 1;
91
92 #[derive(PartialEq)]
93 enum Section {
94 Header,
95 Node,
96 Line,
97 Transformer,
98 Regulation,
99 Other,
100 }
101
102 let mut section = Section::Header;
103
104 for (line_idx, raw) in content.lines().enumerate() {
105 let line_num = line_idx + 1;
106 let trimmed = raw.trim();
107
108 if let Some(stripped) = trimmed.strip_prefix("##") {
110 let tag = stripped.trim_start();
111 let tag_upper = tag.to_uppercase();
112 if tag_upper.starts_with('Z') {
115 continue;
116 }
117 section = if tag_upper.starts_with('N') {
118 Section::Node
119 } else if tag_upper.starts_with('L') {
120 Section::Line
121 } else if tag_upper.starts_with('T') {
122 Section::Transformer
123 } else if tag_upper.starts_with('R') {
124 Section::Regulation
125 } else {
126 Section::Other
127 };
128 continue;
129 }
130
131 if trimmed.is_empty() || trimmed.starts_with("//") {
133 continue;
134 }
135
136 let _ = line_num; match section {
139 Section::Node => {
140 if raw.len() < 8 {
159 continue;
160 }
161
162 let node_id = raw[..8].trim().to_string();
163 if node_id.is_empty() {
164 continue;
165 }
166
167 let parts: Vec<&str> = raw[8..].split_whitespace().collect();
170 if parts.is_empty() {
171 continue;
172 }
173
174 let has_geo_name = parts[0].parse::<f64>().is_err();
175
176 let (base_kv, status, node_type_code, vm, va_deg, pd, qd, pg, qg) = if has_geo_name
177 {
178 let status = parse_required_digit_at(raw, 22, line_num, "status")?;
180 let node_type_code = parse_required_digit_at(raw, 24, line_num, "node_type")?;
181 let numeric: Vec<&str> = if raw.len() > 26 {
182 raw[26..].split_whitespace().collect()
183 } else {
184 vec![]
185 };
186 let vm = parse_optional_f64(numeric.first().copied(), line_num, "vm")?
187 .unwrap_or(0.0);
188 let va_deg = parse_optional_f64(numeric.get(1).copied(), line_num, "va_deg")?
189 .unwrap_or(0.0);
190 let pd =
191 parse_optional_f64(numeric.get(2).copied(), line_num, "pd")?.unwrap_or(0.0);
192 let qd =
193 parse_optional_f64(numeric.get(3).copied(), line_num, "qd")?.unwrap_or(0.0);
194 let pg =
195 parse_optional_f64(numeric.get(4).copied(), line_num, "pg")?.unwrap_or(0.0);
196 let qg =
197 parse_optional_f64(numeric.get(5).copied(), line_num, "qg")?.unwrap_or(0.0);
198 let base_kv = infer_base_kv(&node_id);
199 (base_kv, status, node_type_code, vm, va_deg, pd, qd, pg, qg)
200 } else {
201 let base_kv = parse_required_f64(parts.first().copied(), line_num, "base_kv")?;
203 let status = parse_required_u32(parts.get(1).copied(), line_num, "status")?;
204 let node_type_code =
205 parse_required_u32(parts.get(2).copied(), line_num, "node_type")?;
206 let vm =
207 parse_optional_f64(parts.get(3).copied(), line_num, "vm")?.unwrap_or(1.0);
208 let va_deg = parse_optional_f64(parts.get(4).copied(), line_num, "va_deg")?
209 .unwrap_or(0.0);
210 let pd =
211 parse_optional_f64(parts.get(5).copied(), line_num, "pd")?.unwrap_or(0.0);
212 let qd =
213 parse_optional_f64(parts.get(6).copied(), line_num, "qd")?.unwrap_or(0.0);
214 let pg =
215 parse_optional_f64(parts.get(7).copied(), line_num, "pg")?.unwrap_or(0.0);
216 let qg =
217 parse_optional_f64(parts.get(8).copied(), line_num, "qg")?.unwrap_or(0.0);
218 (base_kv, status, node_type_code, vm, va_deg, pd, qd, pg, qg)
219 };
220
221 let bus_type = match node_type_code {
223 1 => BusType::PV,
224 2 => BusType::Slack,
225 3 => BusType::Isolated,
226 _ => BusType::PQ,
227 };
228
229 if status != 0 {
231 continue; }
233
234 let bus_num = next_num;
235 next_num += 1;
236 node_to_num.insert(node_id, bus_num);
237
238 let mut bus = Bus::new(bus_num, bus_type, base_kv);
239 let vm_pu = if vm > 5.0 && base_kv > 0.0 {
241 vm / base_kv
242 } else if vm > 0.0 {
243 vm
244 } else {
245 1.0
246 };
247 bus.voltage_magnitude_pu = vm_pu;
248 bus.voltage_angle_rad = va_deg.to_radians();
249 if pd.abs() > 1e-10 || qd.abs() > 1e-10 {
251 network.loads.push(Load::new(bus_num, pd, qd));
252 }
253 network.buses.push(bus);
254
255 if pg.abs() > 1e-10 || qg.abs() > 1e-10 {
257 let mut generator = Generator::new(bus_num, pg, vm_pu);
258 generator.q = qg;
259 network.generators.push(generator);
260 }
261 }
262
263 Section::Line => {
264 let parts: Vec<&str> = trimmed.split_whitespace().collect();
267 if parts.len() < 7 {
268 return Err(UcteError::Parse {
269 line: line_num,
270 message: "truncated line record".to_string(),
271 });
272 }
273
274 let from_id = parts[0].to_string();
275 let to_id = parts[1].to_string();
276 let status_idx = if parts.len() >= 8 && parts[3].parse::<u32>().is_ok() {
281 3
282 } else {
283 2
284 };
285 let r_idx = status_idx + 1;
286 let x_idx = r_idx + 1;
287 let b_idx = x_idx + 1;
288 let rate_idx = b_idx + 1;
289
290 if parts.len() <= rate_idx {
291 return Err(UcteError::Parse {
292 line: line_num,
293 message: "truncated line record".to_string(),
294 });
295 }
296
297 let status =
298 parse_required_u32(parts.get(status_idx).copied(), line_num, "status")?;
299 let r = parse_required_f64(parts.get(r_idx).copied(), line_num, "r")?;
300 let x = parse_required_f64(parts.get(x_idx).copied(), line_num, "x")?;
301 let b = parse_required_f64(parts.get(b_idx).copied(), line_num, "b")?;
302 let rate_a = parse_required_f64(parts.get(rate_idx).copied(), line_num, "rate_a")?;
303
304 let from = node_to_num
305 .get(&from_id)
306 .copied()
307 .ok_or_else(|| UcteError::Parse {
308 line: line_num,
309 message: format!("line references unknown from node {from_id}"),
310 })?;
311 let to = node_to_num
312 .get(&to_id)
313 .copied()
314 .ok_or_else(|| UcteError::Parse {
315 line: line_num,
316 message: format!("line references unknown to node {to_id}"),
317 })?;
318
319 let base_kv = network
321 .buses
322 .iter()
323 .find(|bus| bus.number == from)
324 .map(|bus| bus.base_kv)
325 .unwrap_or(1.0);
326 let base_mva = network.base_mva;
327 let z_base = if base_kv > 0.0 && base_mva > 0.0 {
328 base_kv * base_kv / base_mva
329 } else {
330 1.0
331 };
332 let b_base = if z_base > 1e-20 { 1.0 / z_base } else { 1.0 };
333 let r_pu = if z_base > 1e-20 { r / z_base } else { r };
334 let x_pu = if z_base > 1e-20 { x / z_base } else { x };
335 let b_pu = b * 1e-6 / b_base; let mut br = Branch::new_line(from, to, r_pu, x_pu, b_pu);
338 br.rating_a_mva = rate_a;
339 br.in_service = status == 0;
340 network.branches.push(br);
341 }
342
343 Section::Transformer => {
344 let parts: Vec<&str> = trimmed.split_whitespace().collect();
346 if parts.len() < 9 {
347 return Err(UcteError::Parse {
348 line: line_num,
349 message: "truncated transformer record".to_string(),
350 });
351 }
352
353 let from_id = parts[0].to_string();
354 let to_id = parts[1].to_string();
355 let status = parse_required_u32(parts.get(3).copied(), line_num, "status")?;
356 let r = parse_required_f64(parts.get(4).copied(), line_num, "r")?;
357 let x = parse_required_f64(parts.get(5).copied(), line_num, "x")?;
358 let b = parse_required_f64(parts.get(6).copied(), line_num, "b")?;
359 let rated_u1 = parse_required_f64(parts.get(7).copied(), line_num, "rated_u1")?;
360 let rated_u2 = parse_required_f64(parts.get(8).copied(), line_num, "rated_u2")?;
361 let rate_a =
362 parse_optional_f64(parts.get(9).copied(), line_num, "rate_a")?.unwrap_or(0.0);
363
364 let from = node_to_num
365 .get(&from_id)
366 .copied()
367 .ok_or_else(|| UcteError::Parse {
368 line: line_num,
369 message: format!("transformer references unknown from node {from_id}"),
370 })?;
371 let to = node_to_num
372 .get(&to_id)
373 .copied()
374 .ok_or_else(|| UcteError::Parse {
375 line: line_num,
376 message: format!("transformer references unknown to node {to_id}"),
377 })?;
378
379 let base_mva = network.base_mva;
381 let rated_mva = if parts.len() > 10 {
382 let v = parse_required_f64(parts.get(10).copied(), line_num, "rated_mva")?;
383 if v > 0.0 { v } else { base_mva }
384 } else {
385 base_mva
386 };
387 let ratio = if rated_mva > 0.0 {
388 base_mva / rated_mva
389 } else {
390 1.0
391 };
392 let r_pu = r / 100.0 * ratio;
393 let x_pu = x / 100.0 * ratio;
394 let b_ratio = if base_mva > 0.0 {
395 rated_mva / base_mva
396 } else {
397 1.0
398 };
399 let b_pu = b / 100.0 * b_ratio;
400 let tap = if rated_u2 != 0.0 {
401 rated_u1 / rated_u2
402 } else {
403 1.0
404 };
405
406 let mut br = Branch::new_line(from, to, r_pu, x_pu, b_pu);
407 br.tap = tap;
408 br.rating_a_mva = rate_a;
409 br.in_service = status == 0;
410 br.branch_type = BranchType::Transformer;
411 network.branches.push(br);
412 }
413
414 _ => {}
415 }
416 }
417
418 network.base_mva = 100.0;
420
421 let has_slack = network.buses.iter().any(|b| b.bus_type == BusType::Slack);
425 if !has_slack && !network.buses.is_empty() {
426 let gen_by_bus: HashMap<u32, f64> = {
428 let mut m: HashMap<u32, f64> = HashMap::new();
429 for g in &network.generators {
430 *m.entry(g.bus).or_default() += g.p;
431 }
432 m
433 };
434
435 let slack_bus_num = if !gen_by_bus.is_empty() {
437 gen_by_bus
439 .iter()
440 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
441 .map(|(&bus, _)| bus)
442 } else {
443 let mut degree: HashMap<u32, usize> = HashMap::new();
446 for br in &network.branches {
447 *degree.entry(br.from_bus).or_default() += 1;
448 *degree.entry(br.to_bus).or_default() += 1;
449 }
450 degree
451 .iter()
452 .max_by_key(|&(_, d)| *d)
453 .map(|(&bus, _)| bus)
454 .or_else(|| network.buses.first().map(|b| b.number))
455 };
456
457 if let Some(num) = slack_bus_num
458 && let Some(bus) = network.buses.iter_mut().find(|b| b.number == num)
459 {
460 tracing::warn!(
461 "UCTE network has no slack bus; designating bus {} as slack",
462 num
463 );
464 bus.bus_type = BusType::Slack;
465 }
466 }
467
468 for bus in &mut network.buses {
470 if bus.voltage_magnitude_pu <= 0.0 || !bus.voltage_magnitude_pu.is_finite() {
471 bus.voltage_magnitude_pu = 1.0;
472 }
473 }
474 Ok(network)
475}
476
477fn infer_base_kv(node_id: &str) -> f64 {
481 let chars: Vec<char> = node_id.chars().collect();
482 if chars.len() >= 6 {
483 match chars[5] {
484 '0' => 750.0,
485 '1' => 380.0,
486 '2' => 220.0,
487 '3' => 150.0,
488 '4' => 120.0,
489 '5' => 110.0,
490 '6' => 70.0,
491 '7' => 27.0,
492 '8' => 330.0,
493 '9' => 500.0,
494 _ => 1.0,
495 }
496 } else {
497 1.0
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 const SAMPLE_UCTE: &str = r#"##C 2007.05.01;12:00;CSE2;CSE2;0001;test case
506##N
507BUS1110A 110.00 0 2 1.050 0.00 0.0 0.0
508BUS2110A 110.00 0 0 1.020 -5.0 100.0 30.0
509BUS3110A 110.00 0 0 0.980 -10.0 150.0 50.0
510##L
511BUS1110A BUS2110A 1 0 5.0 20.0 200.0 400.0
512BUS2110A BUS3110A 1 0 8.0 30.0 180.0 300.0
513##T
514"#;
515
516 #[test]
517 fn test_ucte_parse_nodes() {
518 let net = parse_str(SAMPLE_UCTE).unwrap();
519 assert_eq!(net.n_buses(), 3);
520 }
521
522 #[test]
523 fn test_ucte_parse_lines() {
524 let net = parse_str(SAMPLE_UCTE).unwrap();
525 assert_eq!(net.n_branches(), 2);
526 }
527
528 #[test]
529 fn test_ucte_slack_bus() {
530 let net = parse_str(SAMPLE_UCTE).unwrap();
531 let slack = net.buses.iter().find(|b| b.bus_type == BusType::Slack);
533 assert!(slack.is_some());
534 }
535
536 #[test]
537 fn test_ucte_load_values() {
538 let net = parse_str(SAMPLE_UCTE).unwrap();
539 let total_load: f64 = net.total_load_mw();
541 assert!((total_load - 250.0).abs() < 1.0);
542 }
543
544 #[test]
545 fn test_ucte_base_kv_inference() {
546 assert!((infer_base_kv("ATBER5GR") - 110.0).abs() < 1.0);
547 assert!((infer_base_kv("ATBER1GR") - 380.0).abs() < 1.0);
548 assert!((infer_base_kv("ATBER2GR") - 220.0).abs() < 1.0);
549 }
550
551 #[test]
552 fn test_ucte_file_parse() {
553 let tmp = std::env::temp_dir().join("surge_ucte_test.uct");
554 std::fs::write(&tmp, SAMPLE_UCTE).unwrap();
555 let net = parse_file(&tmp).unwrap();
556 assert_eq!(net.n_buses(), 3);
557 let _ = std::fs::remove_file(&tmp);
558 }
559
560 #[test]
561 fn test_ucte_parse_simple_line_with_optional_rating() {
562 let doc = r#"##C 2007.05.01;12:00;CSE2;CSE2;0001;test case
563##N
564BUS1110A 110.00 0 2 1.050 0.00 0.0 0.0
565BUS2110A 110.00 0 0 1.020 -5.0 100.0 30.0
566##L
567BUS1110A BUS2110A 1 5.0 20.0 200.0 400.0 450.0
568##T
569"#;
570 let net = parse_str(doc).unwrap();
571 assert_eq!(net.n_branches(), 1);
572 let branch = &net.branches[0];
573 assert!(
574 !branch.in_service,
575 "simple-layout line status should remain aligned with the status column"
576 );
577 assert!((branch.rating_a_mva - 400.0).abs() < 1e-9);
578 }
579
580 #[test]
581 fn test_ucte_rejects_malformed_line_impedance() {
582 let doc = r#"##N
583BUS1110A 110.00 0 2 1.050 0.00 0.0 0.0
584BUS2110A 110.00 0 0 1.020 -5.0 100.0 30.0
585##L
586BUS1110A BUS2110A 1 0 BAD 20.0 200.0 400.0
587"#;
588 let err = parse_str(doc).unwrap_err();
589 assert!(matches!(err, UcteError::Parse { message, .. } if message.contains("invalid r")));
590 }
591
592 #[test]
593 fn test_ucte_rejects_unknown_line_endpoint() {
594 let doc = r#"##N
595BUS1110A 110.00 0 2 1.050 0.00 0.0 0.0
596##L
597BUS1110A BUS9999A 1 0 5.0 20.0 200.0 400.0
598"#;
599 let err = parse_str(doc).unwrap_err();
600 assert!(matches!(
601 err,
602 UcteError::Parse { message, .. } if message.contains("unknown to node BUS9999A")
603 ));
604 }
605}