Skip to main content

surge_io/ucte/
writer.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! UCTE-DEF power system format writer.
3//!
4//! Writes the UCTE Data Exchange Format (UCTE-DEF) used by ENTSO-E for European
5//! grid data interchange. The format uses fixed-width text sections delimited by
6//! `##` markers.
7//!
8//! Sections emitted: `##C` (header), `##N` (nodes/buses), `##L` (lines),
9//! `##T` (transformers).
10
11use 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
27/// Write a Network to a UCTE-DEF file on disk.
28pub 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
34/// Serialize a Network to a UCTE-DEF string.
35pub fn to_string(network: &Network) -> Result<String, UcteWriteError> {
36    let mut out = String::with_capacity(64 * 1024);
37
38    // Build a mapping from bus number to a UCTE node code (8 chars).
39    // If the bus already has a name that looks like a UCTE node code (8 chars,
40    // no whitespace issues), use it directly. Otherwise, synthesize one.
41    let bus_node_codes = build_node_codes(network);
42
43    // Precompute per-bus demand from Load objects.
44    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    // Aggregate generator output per bus for the node record Pg/Qg fields.
49    let gen_per_bus = aggregate_generators(network);
50
51    // Track which buses have any generators (even zero-output ones like
52    // synchronous condensers) so we can emit Pg/Qg to preserve them.
53    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    // --- ##C header ---
61    let now = format_date_string();
62    writeln!(out, "##C {now}")?;
63    writeln!(out, "Exported by Surge (https://github.com/amptimal/surge)")?;
64
65    // --- ##N nodes ---
66    writeln!(out, "##N")?;
67
68    // Group buses by country code (first two chars of node code → ##Z prefix).
69    // Use BTreeMap so zones are emitted in sorted order.
70    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            // Status: 0 = in service (we only write in-service buses)
93            let status = 0u8;
94
95            // Node type: 0=PQ, 1=PV, 2=Slack, 3=Isolated
96            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            // Vm in kV (UCTE convention: values > 5 are in kV)
104            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            // Load values (MW, MVAr) — computed from Load objects.
112            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            // Generator output at this bus (sum of all generators)
117            let (pg, qg) = gen_per_bus.get(&bnum).copied().unwrap_or((0.0, 0.0));
118
119            // Simple UCTE node format (reader's simple path):
120            //   NODECODE base_kv status node_type Vm Va Pd Qd [Pg Qg]
121            // This ensures base_kv round-trips correctly regardless of node code.
122            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            // Write Pg/Qg if this bus has any generators (even if Pg=0).
129            // The reader uses non-zero Pg/Qg as the signal to create a generator,
130            // so for zero-output generators we write a tiny epsilon.
131            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    // --- ##L lines ---
141    writeln!(out, "##L")?;
142
143    // Track order codes for parallel lines between the same pair of buses.
144    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        // Determine order code for parallel lines
161        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        // Status: 0 = in service
171        let status = if br.in_service { 0 } else { 1 };
172
173        // Convert from pu to physical units.
174        // z_base = base_kv^2 / base_mva, b_base = 1/z_base
175        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        // Reverse of reader: b_pu = b_uS * 1e-6 / b_base = b_uS * 1e-6 * z_base
192        // So: b_uS = b_pu / (1e-6 * z_base) = b_pu * b_base * 1e6
193        let b_us = br.b * b_base * 1e6;
194
195        // Current limit: rate_a is in MVA, convert to A if base_kv is known.
196        // I = S / (sqrt(3) * V_kv) * 1000 [A]
197        // However, the UCTE reader just stores rate_a as-is (could be A or MVA).
198        // We store the rate_a field directly since the reader doesn't convert it.
199        let current_limit = br.rating_a_mva;
200
201        // Format: from to order_code status R(Ohm) X(Ohm) B(uS) currentLimit [name]
202        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    // --- ##T transformers ---
217    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        // Order code for parallel transformers
236        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        // Rated voltages: from the bus base_kv values.
248        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        // The reader computes:
262        //   tap = rated_u1 / rated_u2
263        //   r_pu = r_pct / 100 * base_mva / rated_mva
264        //   x_pu = x_pct / 100 * base_mva / rated_mva
265        //   b_pu = b_pct / 100 * rated_mva / base_mva
266        //
267        // So we reverse:
268        //   rated_mva = base_mva (we use system base as the transformer rating)
269        //   r_pct = r_pu * 100 * rated_mva / base_mva = r_pu * 100
270        //   x_pct = x_pu * 100
271        //   b_pct = b_pu * 100 * base_mva / rated_mva = b_pu * 100
272        //
273        // For the actual rated voltages, the reader derives tap = u1/u2.
274        // We want tap = rated_u1_actual / rated_u2_actual. The bus base_kv values
275        // are the nominal voltages; the off-nominal ratio is in br.tap.
276        // So: rated_u1_actual = rated_u1 (from bus kV), rated_u2_actual = rated_u1 / br.tap
277        // But this only works if tap = rated_u1/rated_u2 from the reader. Since we
278        // may have lost the original rated voltages, we reconstruct:
279        //   rated_u1_write = rated_u1 (from bus base_kv)
280        //   rated_u2_write = rated_u1 / br.tap
281        // This ensures that on re-read: tap = rated_u1_write / rated_u2_write = br.tap
282        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; // Use system base as transformer rating
290
291        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; // UCTE G% (typically zero, unused in current format)
295
296        let current_limit = br.rating_a_mva;
297
298        // Format must match reader expectation:
299        //   from to order_code status R% X% B% ratedU1 ratedU2 rateA [ratedMVA]
300        // The reader parses: parts[4]=R, parts[5]=X, parts[6]=B,
301        //   parts[7]=ratedU1, parts[8]=ratedU2, parts[9]=rateA, parts[10]=ratedMVA
302        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    // --- ##R regulation (empty — we don't have regulation data in the Network model) ---
320    writeln!(out, "##R")?;
321
322    Ok(out)
323}
324
325/// Build a mapping from bus number to UCTE 8-character node code.
326///
327/// If a bus has a name that looks like a valid UCTE node code (exactly 8 chars
328/// or is a recognizable UCTE ID), use it. Otherwise, generate a synthetic code
329/// using the country prefix "XX" and the voltage level character.
330fn 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    // First pass: try to use existing bus names as node codes
335    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        // Will be handled in second pass
346    }
347
348    // Second pass: generate synthetic codes for buses without valid names
349    let mut synth_counter: u32 = 1;
350    for bus in &network.buses {
351        if codes.contains_key(&bus.number) {
352            continue;
353        }
354
355        // Voltage level character (UCTE convention, position 6 of 8-char code):
356        // 0=750kV, 1=380kV, 2=220kV, 3=150kV, 4=120kV, 5=110kV, 6=70kV, 7=27kV, 8=330kV, 9=500kV
357        let vlevel = voltage_level_char(bus.base_kv);
358
359        loop {
360            // Format: XNNNN_V_ where N is a counter, V is voltage level
361            // e.g., X0001_1_ for 380kV bus #1
362            let code = format!("X{:04}{vlevel}{:02}", synth_counter, bus.number % 100);
363            // Ensure exactly 8 chars
364            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
383/// Map a base_kv value to the UCTE voltage level character (index 6 of node code).
384fn voltage_level_char(base_kv: f64) -> char {
385    if base_kv >= 700.0 {
386        '0' // 750 kV
387    } else if base_kv >= 450.0 {
388        '9' // 500 kV
389    } else if base_kv >= 350.0 {
390        '1' // 380 kV
391    } else if base_kv >= 300.0 {
392        '8' // 330 kV
393    } else if base_kv >= 200.0 {
394        '2' // 220 kV
395    } else if base_kv >= 140.0 {
396        '3' // 150 kV
397    } else if base_kv >= 115.0 {
398        '4' // 120 kV
399    } else if base_kv >= 90.0 {
400        '5' // 110 kV
401    } else if base_kv >= 50.0 {
402        '6' // 70 kV
403    } else {
404        '7' // 27 kV and below
405    }
406}
407
408/// Extract the country code (first two characters) from a UCTE node code.
409/// Returns "XX" for synthetic or unrecognizable codes.
410fn 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        // UCTE country codes are two uppercase letters (e.g., FR, DE, BE, NL)
414        // or special prefixes like X (cross-border), 0-9 (legacy)
415        first_two.to_uppercase()
416    } else {
417        "XX".to_string()
418    }
419}
420
421/// Build a 12-character geographic name for the UCTE node record.
422/// Aggregate generator Pg and Qg per bus number.
423fn 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
436/// Format the current date as "YYYY.MM.DD" for the ##C header.
437fn format_date_string() -> String {
438    // Use a simple approach: read from system time
439    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    // Simple date calculation from Unix timestamp
446    let days = secs / 86400;
447    let (year, month, day) = days_to_ymd(days);
448    format!("{:04}.{:02}.{:02}", year, month, day)
449}
450
451/// Convert days since Unix epoch to (year, month, day).
452fn days_to_ymd(mut days: i64) -> (i64, u32, u32) {
453    // Civil calendar from days since epoch (algorithm from Howard Hinnant)
454    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; // day of era [0, 146096]
461    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era
462    let y = yoe as i64 + era * 400;
463    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
464    let mp = (5 * doy + 2) / 153; // month prime [0, 11]
465    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
466    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
467    let y = if m <= 2 { y + 1 } else { y };
468    (y, m, d)
469}
470
471/// Format current limit as a right-aligned integer string.
472fn 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        // Slack bus at 110 kV
491        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        // PQ bus at 110 kV with load
498        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        // PQ bus at 110 kV with load
506        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        // Generator on bus 1
514        let mut g = Generator::new(1, 250.0, 1.05);
515        g.q = 80.0;
516        net.generators.push(g);
517
518        // Lines — use pu values as if they came from the reader
519        // z_base = 110^2 / 100 = 121 ohm
520        // Line 1-2: r=5 ohm, x=20 ohm, b=200 uS
521        let z_base = 110.0 * 110.0 / 100.0; // 121.0
522        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        // Line 2-3: r=8 ohm, x=30 ohm, b=180 uS
532        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        // Transformer: tap = 400/225 = 1.7778
556        // r_pu = 0.55/100 = 0.0055, x_pu = 1.68/100 = 0.0168
557        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        // Both lines should appear in the ##L section
590        let l_section = s.split("##L").nth(1).unwrap_or("");
591        let l_section = l_section.split("##T").next().unwrap_or(l_section);
592        // Should have two line records
593        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        // Compare first branch impedance (should be close after ohm→pu→ohm→pu)
658        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        // Bus without a valid 8-char name should get a synthetic code
705        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(); // Not 8 chars
709        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}