1use std::collections::{BTreeMap, BTreeSet};
12use std::fmt::Write as FmtWrite;
13use std::path::Path;
14
15use surge_network::Network;
16use surge_network::network::BusType;
17use thiserror::Error;
18
19#[derive(Error, Debug)]
20pub enum UcteWriteError {
21 #[error("I/O error: {0}")]
22 Io(#[from] std::io::Error),
23 #[error("format error: {0}")]
24 Fmt(#[from] std::fmt::Error),
25}
26
27pub fn write_file(network: &Network, path: &Path) -> Result<(), UcteWriteError> {
29 let content = to_string(network)?;
30 std::fs::write(path, content)?;
31 Ok(())
32}
33
34pub fn to_string(network: &Network) -> Result<String, UcteWriteError> {
36 let mut out = String::with_capacity(64 * 1024);
37
38 let bus_node_codes = build_node_codes(network);
42
43 let bus_demand_p = network.bus_load_p_mw();
45 let bus_demand_q = network.bus_load_q_mvar();
46 let bus_idx_map = network.bus_index_map();
47
48 let gen_per_bus = aggregate_generators(network);
50
51 let buses_with_gens: BTreeSet<u32> = network
54 .generators
55 .iter()
56 .filter(|g| g.in_service)
57 .map(|g| g.bus)
58 .collect();
59
60 let now = format_date_string();
62 writeln!(out, "##C {now}")?;
63 writeln!(out, "Exported by Surge (https://github.com/amptimal/surge)")?;
64
65 writeln!(out, "##N")?;
67
68 let mut zone_groups: BTreeMap<String, Vec<u32>> = BTreeMap::new();
71 for bus in &network.buses {
72 let code = &bus_node_codes[&bus.number];
73 let zone_key = extract_country_code(code);
74 zone_groups.entry(zone_key).or_default().push(bus.number);
75 }
76
77 for (zone, bus_numbers) in &zone_groups {
78 writeln!(out, "##Z{zone}")?;
79 for &bnum in bus_numbers {
80 let bus = network
81 .buses
82 .iter()
83 .find(|b| b.number == bnum)
84 .ok_or_else(|| {
85 std::io::Error::new(
86 std::io::ErrorKind::InvalidData,
87 format!("bus {} not found in network", bnum),
88 )
89 })?;
90 let node_code = &bus_node_codes[&bnum];
91
92 let status = 0u8;
94
95 let node_type = match bus.bus_type {
97 BusType::PQ => 0u8,
98 BusType::PV => 1,
99 BusType::Slack => 2,
100 BusType::Isolated => 3,
101 };
102
103 let vm_kv = if bus.base_kv > 0.0 {
105 bus.voltage_magnitude_pu * bus.base_kv
106 } else {
107 bus.voltage_magnitude_pu
108 };
109 let va_deg = bus.voltage_angle_rad.to_degrees();
110
111 let bi = bus_idx_map.get(&bnum).copied().unwrap_or(0);
113 let pd = bus_demand_p.get(bi).copied().unwrap_or(0.0);
114 let qd = bus_demand_q.get(bi).copied().unwrap_or(0.0);
115
116 let (pg, qg) = gen_per_bus.get(&bnum).copied().unwrap_or((0.0, 0.0));
118
119 write!(
123 out,
124 "{:<8} {:.2} {} {} {:.5} {:.5} {:.5} {:.5}",
125 node_code, bus.base_kv, status, node_type, vm_kv, va_deg, pd, qd
126 )?;
127
128 if buses_with_gens.contains(&bnum) {
132 let pg_out = if pg.abs() < 1e-10 { 1e-6 } else { pg };
133 write!(out, " {:.6} {:.6}", pg_out, qg)?;
134 }
135
136 writeln!(out)?;
137 }
138 }
139
140 writeln!(out, "##L")?;
142
143 let mut line_order: BTreeMap<(String, String), u32> = BTreeMap::new();
145
146 for br in &network.branches {
147 if br.is_transformer() {
148 continue;
149 }
150
151 let from_code = match bus_node_codes.get(&br.from_bus) {
152 Some(c) => c.clone(),
153 None => continue,
154 };
155 let to_code = match bus_node_codes.get(&br.to_bus) {
156 Some(c) => c.clone(),
157 None => continue,
158 };
159
160 let pair_key = if from_code <= to_code {
162 (from_code.clone(), to_code.clone())
163 } else {
164 (to_code.clone(), from_code.clone())
165 };
166 let order = line_order.entry(pair_key).or_insert(0);
167 *order += 1;
168 let order_code = *order;
169
170 let status = if br.in_service { 0 } else { 1 };
172
173 let base_kv = network
176 .buses
177 .iter()
178 .find(|b| b.number == br.from_bus)
179 .map(|b| b.base_kv)
180 .unwrap_or(1.0);
181 let base_mva = network.base_mva;
182 let z_base = if base_mva > 0.0 {
183 base_kv * base_kv / base_mva
184 } else {
185 1.0
186 };
187 let b_base = if z_base > 0.0 { 1.0 / z_base } else { 1.0 };
188
189 let r_ohm = br.r * z_base;
190 let x_ohm = br.x * z_base;
191 let b_us = br.b * b_base * 1e6;
194
195 let current_limit = br.rating_a_mva;
200
201 writeln!(
203 out,
204 "{} {} {} {} {:.4} {:.4} {:.6} {:>6}",
205 from_code,
206 to_code,
207 order_code,
208 status,
209 r_ohm,
210 x_ohm,
211 b_us,
212 format_current_limit(current_limit)
213 )?;
214 }
215
216 writeln!(out, "##T")?;
218
219 let mut xfmr_order: BTreeMap<(String, String), u32> = BTreeMap::new();
220
221 for br in &network.branches {
222 if !br.is_transformer() {
223 continue;
224 }
225
226 let from_code = match bus_node_codes.get(&br.from_bus) {
227 Some(c) => c.clone(),
228 None => continue,
229 };
230 let to_code = match bus_node_codes.get(&br.to_bus) {
231 Some(c) => c.clone(),
232 None => continue,
233 };
234
235 let pair_key = if from_code <= to_code {
237 (from_code.clone(), to_code.clone())
238 } else {
239 (to_code.clone(), from_code.clone())
240 };
241 let order = xfmr_order.entry(pair_key).or_insert(0);
242 *order += 1;
243 let order_code = *order;
244
245 let status = if br.in_service { 0 } else { 1 };
246
247 let rated_u1 = network
249 .buses
250 .iter()
251 .find(|b| b.number == br.from_bus)
252 .map(|b| b.base_kv)
253 .unwrap_or(1.0);
254 let rated_u2 = network
255 .buses
256 .iter()
257 .find(|b| b.number == br.to_bus)
258 .map(|b| b.base_kv)
259 .unwrap_or(1.0);
260
261 let rated_u2_write = if br.tap.abs() > 1e-10 {
283 rated_u1 / br.tap
284 } else {
285 rated_u2
286 };
287
288 let base_mva = network.base_mva;
289 let rated_mva = base_mva; let r_pct = br.r * 100.0 * rated_mva / base_mva;
292 let x_pct = br.x * 100.0 * rated_mva / base_mva;
293 let b_pct = br.b * 100.0 * base_mva / rated_mva;
294 let _g_pct = 0.0; let current_limit = br.rating_a_mva;
297
298 writeln!(
303 out,
304 "{} {} {} {} {:.4} {:.3} {:.6} {:.1} {:.1} {:>6} {:.1}",
305 from_code,
306 to_code,
307 order_code,
308 status,
309 r_pct,
310 x_pct,
311 b_pct,
312 rated_u1,
313 rated_u2_write,
314 format_current_limit(current_limit),
315 rated_mva
316 )?;
317 }
318
319 writeln!(out, "##R")?;
321
322 Ok(out)
323}
324
325fn build_node_codes(network: &Network) -> BTreeMap<u32, String> {
331 let mut codes: BTreeMap<u32, String> = BTreeMap::new();
332 let mut used: BTreeSet<String> = BTreeSet::new();
333
334 for bus in &network.buses {
336 let name = bus.name.trim();
337 if name.len() == 8 && name.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
338 let code = name.to_string();
339 if !used.contains(&code) {
340 used.insert(code.clone());
341 codes.insert(bus.number, code);
342 continue;
343 }
344 }
345 }
347
348 let mut synth_counter: u32 = 1;
350 for bus in &network.buses {
351 if codes.contains_key(&bus.number) {
352 continue;
353 }
354
355 let vlevel = voltage_level_char(bus.base_kv);
358
359 loop {
360 let code = format!("X{:04}{vlevel}{:02}", synth_counter, bus.number % 100);
363 let code = if code.len() > 8 {
365 code[..8].to_string()
366 } else if code.len() < 8 {
367 format!("{:<8}", code)
368 } else {
369 code
370 };
371 synth_counter += 1;
372 if !used.contains(&code) {
373 used.insert(code.clone());
374 codes.insert(bus.number, code);
375 break;
376 }
377 }
378 }
379
380 codes
381}
382
383fn voltage_level_char(base_kv: f64) -> char {
385 if base_kv >= 700.0 {
386 '0' } else if base_kv >= 450.0 {
388 '9' } else if base_kv >= 350.0 {
390 '1' } else if base_kv >= 300.0 {
392 '8' } else if base_kv >= 200.0 {
394 '2' } else if base_kv >= 140.0 {
396 '3' } else if base_kv >= 115.0 {
398 '4' } else if base_kv >= 90.0 {
400 '5' } else if base_kv >= 50.0 {
402 '6' } else {
404 '7' }
406}
407
408fn extract_country_code(node_code: &str) -> String {
411 if node_code.len() >= 2 {
412 let first_two: String = node_code.chars().take(2).collect();
413 first_two.to_uppercase()
416 } else {
417 "XX".to_string()
418 }
419}
420
421fn aggregate_generators(network: &Network) -> BTreeMap<u32, (f64, f64)> {
424 let mut gen_per_bus: BTreeMap<u32, (f64, f64)> = BTreeMap::new();
425 for g in &network.generators {
426 if !g.in_service {
427 continue;
428 }
429 let entry = gen_per_bus.entry(g.bus).or_insert((0.0, 0.0));
430 entry.0 += g.p;
431 entry.1 += g.q;
432 }
433 gen_per_bus
434}
435
436fn format_date_string() -> String {
438 let now = std::time::SystemTime::now();
440 let duration = now
441 .duration_since(std::time::UNIX_EPOCH)
442 .unwrap_or_default();
443 let secs = duration.as_secs() as i64;
444
445 let days = secs / 86400;
447 let (year, month, day) = days_to_ymd(days);
448 format!("{:04}.{:02}.{:02}", year, month, day)
449}
450
451fn days_to_ymd(mut days: i64) -> (i64, u32, u32) {
453 days += 719468;
455 let era = if days >= 0 {
456 days / 146097
457 } else {
458 (days - 146096) / 146097
459 };
460 let doe = (days - era * 146097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
463 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
468 (y, m, d)
469}
470
471fn format_current_limit(rate: f64) -> String {
473 if rate > 0.0 {
474 format!("{}", rate as u64)
475 } else {
476 "0".to_string()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use surge_network::Network;
484 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
485
486 fn simple_network() -> Network {
487 let mut net = Network::new("ucte_test");
488 net.base_mva = 100.0;
489
490 let mut slack = Bus::new(1, BusType::Slack, 110.0);
492 slack.voltage_magnitude_pu = 1.05;
493 slack.voltage_angle_rad = 0.0;
494 slack.name = "BUS1110A".to_string();
495 net.buses.push(slack);
496
497 let mut pq = Bus::new(2, BusType::PQ, 110.0);
499 pq.voltage_magnitude_pu = 1.02;
500 pq.voltage_angle_rad = (-5.0_f64).to_radians();
501 pq.name = "BUS2110A".to_string();
502 net.buses.push(pq);
503 net.loads.push(Load::new(2, 100.0, 30.0));
504
505 let mut pq2 = Bus::new(3, BusType::PQ, 110.0);
507 pq2.voltage_magnitude_pu = 0.98;
508 pq2.voltage_angle_rad = (-10.0_f64).to_radians();
509 pq2.name = "BUS3110A".to_string();
510 net.buses.push(pq2);
511 net.loads.push(Load::new(3, 150.0, 50.0));
512
513 let mut g = Generator::new(1, 250.0, 1.05);
515 g.q = 80.0;
516 net.generators.push(g);
517
518 let z_base = 110.0 * 110.0 / 100.0; let b_base = 1.0 / z_base;
523 net.branches.push(Branch::new_line(
524 1,
525 2,
526 5.0 / z_base,
527 20.0 / z_base,
528 200.0 * 1e-6 / b_base,
529 ));
530
531 let mut br2 = Branch::new_line(2, 3, 8.0 / z_base, 30.0 / z_base, 180.0 * 1e-6 / b_base);
533 br2.rating_a_mva = 300.0;
534 net.branches.push(br2);
535
536 net
537 }
538
539 fn transformer_network() -> Network {
540 let mut net = Network::new("ucte_xfmr_test");
541 net.base_mva = 100.0;
542
543 let mut bus1 = Bus::new(1, BusType::Slack, 400.0);
544 bus1.voltage_magnitude_pu = 1.0;
545 bus1.name = "FHVBUS1A".to_string();
546 net.buses.push(bus1);
547
548 let mut bus2 = Bus::new(2, BusType::PQ, 225.0);
549 bus2.name = "FLVBUS2A".to_string();
550 net.buses.push(bus2);
551 net.loads.push(Load::new(2, 100.0, 0.0));
552
553 net.generators.push(Generator::new(1, 100.0, 1.0));
554
555 let mut br = Branch::new_line(1, 2, 0.0055, 0.0168, 0.001325);
558 br.tap = 400.0 / 225.0;
559 br.rating_a_mva = 5000.0;
560 net.branches.push(br);
561
562 net
563 }
564
565 #[test]
566 fn test_ucte_write_produces_sections() {
567 let net = simple_network();
568 let s = to_string(&net).unwrap();
569 assert!(s.contains("##C"), "missing ##C header");
570 assert!(s.contains("##N"), "missing ##N section");
571 assert!(s.contains("##L"), "missing ##L section");
572 assert!(s.contains("##T"), "missing ##T section");
573 assert!(s.contains("##R"), "missing ##R section");
574 }
575
576 #[test]
577 fn test_ucte_write_node_codes_preserved() {
578 let net = simple_network();
579 let s = to_string(&net).unwrap();
580 assert!(s.contains("BUS1110A"), "node code BUS1110A not found");
581 assert!(s.contains("BUS2110A"), "node code BUS2110A not found");
582 assert!(s.contains("BUS3110A"), "node code BUS3110A not found");
583 }
584
585 #[test]
586 fn test_ucte_write_lines_present() {
587 let net = simple_network();
588 let s = to_string(&net).unwrap();
589 let l_section = s.split("##L").nth(1).unwrap_or("");
591 let l_section = l_section.split("##T").next().unwrap_or(l_section);
592 let line_count = l_section.lines().filter(|l| !l.trim().is_empty()).count();
594 assert_eq!(line_count, 2, "expected 2 line records, got {line_count}");
595 }
596
597 #[test]
598 fn test_ucte_write_transformer() {
599 let net = transformer_network();
600 let s = to_string(&net).unwrap();
601 let t_section = s.split("##T").nth(1).unwrap_or("");
602 let t_section = t_section.split("##R").next().unwrap_or(t_section);
603 let xfmr_count = t_section.lines().filter(|l| !l.trim().is_empty()).count();
604 assert_eq!(
605 xfmr_count, 1,
606 "expected 1 transformer record, got {xfmr_count}"
607 );
608 }
609
610 #[test]
611 fn test_ucte_roundtrip_node_count() {
612 use crate::ucte::parse_str;
613 let net = simple_network();
614 let s = to_string(&net).unwrap();
615 let net2 = parse_str(&s).unwrap();
616 assert_eq!(
617 net2.n_buses(),
618 net.n_buses(),
619 "bus count mismatch after round-trip"
620 );
621 }
622
623 #[test]
624 fn test_ucte_roundtrip_branch_count() {
625 use crate::ucte::parse_str;
626 let net = simple_network();
627 let s = to_string(&net).unwrap();
628 let net2 = parse_str(&s).unwrap();
629 assert_eq!(
630 net2.n_branches(),
631 net.n_branches(),
632 "branch count mismatch after round-trip"
633 );
634 }
635
636 #[test]
637 fn test_ucte_roundtrip_load_values() {
638 use crate::ucte::parse_str;
639 let net = simple_network();
640 let s = to_string(&net).unwrap();
641 let net2 = parse_str(&s).unwrap();
642 let total_pd_orig: f64 = net.total_load_mw();
643 let total_pd_rt: f64 = net2.total_load_mw();
644 assert!(
645 (total_pd_orig - total_pd_rt).abs() < 1.0,
646 "load mismatch: {total_pd_orig:.2} vs {total_pd_rt:.2}"
647 );
648 }
649
650 #[test]
651 fn test_ucte_roundtrip_impedance() {
652 use crate::ucte::parse_str;
653 let net = simple_network();
654 let s = to_string(&net).unwrap();
655 let net2 = parse_str(&s).unwrap();
656
657 let br1_orig = &net.branches[0];
659 let br1_rt = &net2.branches[0];
660 let r_tol = 1e-3;
661 let x_tol = 1e-3;
662 assert!(
663 (br1_orig.r - br1_rt.r).abs() / br1_orig.r.max(1e-10) < r_tol,
664 "R mismatch: orig={} rt={}",
665 br1_orig.r,
666 br1_rt.r
667 );
668 assert!(
669 (br1_orig.x - br1_rt.x).abs() / br1_orig.x.max(1e-10) < x_tol,
670 "X mismatch: orig={} rt={}",
671 br1_orig.x,
672 br1_rt.x
673 );
674 }
675
676 #[test]
677 fn test_ucte_file_write() {
678 let net = simple_network();
679 let tmp = std::env::temp_dir().join("surge_ucte_writer_test.uct");
680 write_file(&net, &tmp).unwrap();
681 let content = std::fs::read_to_string(&tmp).unwrap();
682 assert!(content.contains("##C"), "missing ##C in file output");
683 assert!(content.contains("##N"), "missing ##N in file output");
684 assert!(content.contains("##L"), "missing ##L in file output");
685 let _ = std::fs::remove_file(&tmp);
686 }
687
688 #[test]
689 fn test_voltage_level_char_mapping() {
690 assert_eq!(voltage_level_char(750.0), '0');
691 assert_eq!(voltage_level_char(500.0), '9');
692 assert_eq!(voltage_level_char(380.0), '1');
693 assert_eq!(voltage_level_char(330.0), '8');
694 assert_eq!(voltage_level_char(220.0), '2');
695 assert_eq!(voltage_level_char(150.0), '3');
696 assert_eq!(voltage_level_char(120.0), '4');
697 assert_eq!(voltage_level_char(110.0), '5');
698 assert_eq!(voltage_level_char(70.0), '6');
699 assert_eq!(voltage_level_char(27.0), '7');
700 }
701
702 #[test]
703 fn test_synthetic_node_code_generation() {
704 let mut net = Network::new("synth_test");
706 net.base_mva = 100.0;
707 let mut bus = Bus::new(1, BusType::PQ, 110.0);
708 bus.name = "short".to_string(); net.buses.push(bus);
710
711 let codes = build_node_codes(&net);
712 let code = &codes[&1];
713 assert_eq!(code.len(), 8, "synthetic code must be 8 chars: '{code}'");
714 }
715}