Skip to main content

surge_io/ucte/
reader.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! UCTE-DEF power system format reader.
3//!
4//! Parses the UCTE Data Exchange Format (UCTE-DEF) used by ENTSO-E for European
5//! grid data interchange. The format uses text sections delimited by ## markers.
6//!
7//! Sections: ##N (nodes/buses), ##L (lines), ##T (transformers), ##R (regulation)
8
9use 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
79/// Parse a UCTE-DEF file from disk.
80pub fn parse_file(path: &Path) -> Result<Network, UcteError> {
81    let content = std::fs::read_to_string(path)?;
82    parse_str(&content)
83}
84
85/// Parse a UCTE-DEF string.
86pub fn parse_str(content: &str) -> Result<Network, UcteError> {
87    let mut network = Network::new("ucte_network");
88    // Map from UCTE node name (8 chars) to internal bus number
89    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        // Section markers
109        if let Some(stripped) = trimmed.strip_prefix("##") {
110            let tag = stripped.trim_start();
111            let tag_upper = tag.to_uppercase();
112            // ##Z* tags (e.g. ##ZFR, ##ZDE) are zone sub-group markers within ##N;
113            // they do NOT change the current section.
114            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        // Skip blank lines and comments
132        if trimmed.is_empty() || trimmed.starts_with("//") {
133            continue;
134        }
135
136        let _ = line_num; // suppress unused warning
137
138        match section {
139            Section::Node => {
140                // UCTE-DEF node format has two variants:
141                //
142                // Simple (no geographic name — used in some synthetic test files):
143                //   "NODECODE base_kv status node_type Vm Va Pd Qd [Pg Qg ...]"
144                //   All fields space-delimited; parts[1] is a parseable float (base_kv).
145                //
146                // Full UCTE-DEF (real-world files from ENTSO-E, PowSyBl, etc.):
147                //   "NODECODE<sp>GEONAME_12CH<sp>status<sp>node_type<sp>Vm Va Pd Qd [...]"
148                //   Position 0-7: node code (8 chars)
149                //   Position 8:   space
150                //   Position 9-20: geographic name (12 chars, may contain spaces)
151                //   Position 21:  space
152                //   Position 22:  status (single char: 0=connected, 8=equivalent)
153                //   Position 23:  space
154                //   Position 24:  node type (single char: 0=PQ, 1=PV, 2=slack, 3=isolated)
155                //   Position 25:  space
156                //   Position 26+: numeric fields (Vm kV, Va deg, Pd, Qd, Pg, Qg, ...)
157
158                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                // Detect format by testing whether field at offset 9 looks like
168                // the beginning of a float (simple format) or text (full UCTE format).
169                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                    // Full UCTE-DEF fixed-width format
179                    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                    // Simple space-delimited format (no geographic name)
202                    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                // Node type: 0=PQ, 1=PV, 2=Slack, 3=isolated
222                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                // Status: 0=in service, others=outage
230                if status != 0 {
231                    continue; // skip outaged nodes
232                }
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                // In UCTE, Vm may be in kV or pu (>5 → kV heuristic)
240                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                // Synthesize Load object from bus-level Pd/Qd (UCTE has no separate load records).
250                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                // Create a generator if the node has non-zero generation
256                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                // UCTE line: "from_node to_node status r x b rateA [rateB rateC]"
265                // or extended: "from_node to_node order_code status r x b currentLimit"
266                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                // parts[2] may be order_code or status depending on file version.
277                // Extended records carry an explicit integer status token at [3];
278                // simple records keep status at [2] even when optional ratings
279                // are present.
280                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                // UCTE r, x in Ohm; b in µS — convert to pu
320                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; // µS → S → pu
336
337                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                // UCTE transformer: "from to order_code status r x b ratedU1 ratedU2 rateA"
345                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                // Convert r, x from % on transformer base to pu on system base
380                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    // Default base_mva = 100 (UCTE doesn't specify it)
419    network.base_mva = 100.0;
420
421    // Designate a slack bus if none exists.
422    // UCTE format does not always mark a slack bus.  Choose the bus connected
423    // to the largest generator (largest Pg injection) as the reference.
424    let has_slack = network.buses.iter().any(|b| b.bus_type == BusType::Slack);
425    if !has_slack && !network.buses.is_empty() {
426        // Collect total generation per bus from generators (if any)
427        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        // Also consider negative load (net injection) as generation proxy
436        let slack_bus_num = if !gen_by_bus.is_empty() {
437            // Choose the bus with the largest total generation
438            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            // No generators found — pick the first bus connected to the most branches
444            // (a heuristic for the most central bus)
445            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    // Ensure all buses have vm > 0 (some UCTE files have vm=0 which means "no data").
469    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
477/// Infer base voltage from UCTE node name encoding.
478/// UCTE node names encode the voltage level as the 6th character (0-indexed: 5):
479/// 0=750kV, 1=380kV, 2=220kV, 3=150kV, 4=120kV, 5=110kV, 6=70kV, 7=27kV, 8=330kV, 9=500kV
480fn 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        // BUS1110A has node_type=2 → Slack
532        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        // BUS2 has pd=100, BUS3 has pd=150
540        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}