Skip to main content

surge_io/epc/
reader.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! GE PSLF EPC (.epc) file parser.
3//!
4//! Parses the GE PSLF Electric Power Case format used in WECC base-case
5//! exchange and TAMU ACTIVSg synthetic test cases.
6//!
7//! # File Structure
8//! - `title` / `comments` / `solution parameters` — preamble sections delimited by `!`
9//! - Named data sections: `bus data [N] ...`, `branch data [N] ...`, etc.
10//! - Records are space-delimited with double-quoted strings
11//! - Records have identity fields (bus/branch endpoints) before `:` and data fields after
12//! - Continuation lines end with `/` — joined before parsing
13//! - Section count `[N]` in header gives the record count
14//!
15//! # Supported Sections
16//! - Bus Data (type, voltage, angle, area, zone, limits, coordinates)
17//! - Branch Data (2-line records: impedance, ratings, status)
18//! - Transformer Data (4-line records: impedance, tap, ratings)
19//! - Generator Data (2-line records: dispatch, limits, machine base)
20//! - Load Data (constant P/Q, I-dependent, Z-dependent)
21//! - Shunt Data (fixed bus shunts)
22//! - SVD Data (switched shunts — modeled as fixed at operating point)
23//! - Area Data
24//! - Zone Data
25//! - DC Bus / DC Line / DC Converter / VS Converter Data
26
27use std::collections::HashMap;
28use std::path::Path;
29
30use surge_network::Network;
31use surge_network::network::AreaSchedule;
32use surge_network::network::{Branch, BranchType, Bus, BusType, Generator};
33use thiserror::Error;
34
35// ---------------------------------------------------------------------------
36// Error type
37// ---------------------------------------------------------------------------
38
39#[derive(Error, Debug)]
40pub enum EpcError {
41    #[error("I/O error: {0}")]
42    Io(#[from] std::io::Error),
43
44    #[error("parse error on line {line}: {message}")]
45    Parse { line: usize, message: String },
46
47    #[error("missing section: {0}")]
48    MissingSection(String),
49
50    #[error("unexpected end of file in {0} section")]
51    UnexpectedEof(String),
52
53    #[error("non-finite float value on line {line}: {message}")]
54    NonFiniteValue { line: usize, message: String },
55}
56
57// ---------------------------------------------------------------------------
58// Public API
59// ---------------------------------------------------------------------------
60
61/// Parse a GE PSLF EPC file from disk.
62pub fn parse_file(path: &Path) -> Result<Network, EpcError> {
63    let content = std::fs::read_to_string(path)?;
64    let name = path
65        .file_stem()
66        .and_then(|s| s.to_str())
67        .unwrap_or("unknown")
68        .to_string();
69    parse_string_with_name(&content, &name)
70}
71
72/// Parse a GE PSLF EPC case from a string.
73pub fn parse_str(content: &str) -> Result<Network, EpcError> {
74    parse_string_with_name(content, "unknown")
75}
76
77// ---------------------------------------------------------------------------
78// Intermediary types
79// ---------------------------------------------------------------------------
80
81use crate::parse_utils::{RawLoad, RawShunt, apply_loads, apply_shunts};
82
83// ---------------------------------------------------------------------------
84// Section detection
85// ---------------------------------------------------------------------------
86
87#[derive(Debug, PartialEq)]
88enum EpcSection {
89    Title,
90    Comments,
91    SolutionParameters,
92    SubstationData,
93    BusData,
94    BranchData,
95    TransformerData,
96    GeneratorData,
97    LoadData,
98    ShuntData,
99    SvdData,
100    AreaData,
101    ZoneData,
102    InterfaceData,
103    InterfaceBranchData,
104    DcBusData,
105    DcLineData,
106    DcConverterData,
107    VsConverterData,
108    ZTableData,
109    GcdData,
110    TransactionData,
111    OwnerData,
112    QtableData,
113    BaData,
114    InjGroupData,
115    InjGrpElemData,
116    End,
117}
118
119/// Detect which section a line begins (case-insensitive match on section name).
120fn detect_section(line: &str) -> Option<EpcSection> {
121    let lower = line.trim().to_ascii_lowercase();
122    // Order matters — match more specific names first to avoid prefix collisions.
123    if lower == "title" {
124        return Some(EpcSection::Title);
125    }
126    if lower == "comments" {
127        return Some(EpcSection::Comments);
128    }
129    if lower.starts_with("solution parameters") || lower.starts_with("solution_parameters") {
130        return Some(EpcSection::SolutionParameters);
131    }
132    if lower.starts_with("substation data") {
133        return Some(EpcSection::SubstationData);
134    }
135    if lower.starts_with("bus data") {
136        return Some(EpcSection::BusData);
137    }
138    if lower.starts_with("branch data") {
139        return Some(EpcSection::BranchData);
140    }
141    if lower.starts_with("transformer data") {
142        return Some(EpcSection::TransformerData);
143    }
144    if lower.starts_with("generator data") {
145        return Some(EpcSection::GeneratorData);
146    }
147    if lower.starts_with("load data") {
148        return Some(EpcSection::LoadData);
149    }
150    if lower.starts_with("shunt data") {
151        return Some(EpcSection::ShuntData);
152    }
153    if lower.starts_with("svd data") {
154        return Some(EpcSection::SvdData);
155    }
156    if lower.starts_with("interface branch data") {
157        return Some(EpcSection::InterfaceBranchData);
158    }
159    if lower.starts_with("interface data") {
160        return Some(EpcSection::InterfaceData);
161    }
162    if lower.starts_with("area data") {
163        return Some(EpcSection::AreaData);
164    }
165    if lower.starts_with("zone data") {
166        return Some(EpcSection::ZoneData);
167    }
168    if lower.starts_with("dc bus data") {
169        return Some(EpcSection::DcBusData);
170    }
171    if lower.starts_with("dc line data") {
172        return Some(EpcSection::DcLineData);
173    }
174    if lower.starts_with("dc converter data") {
175        return Some(EpcSection::DcConverterData);
176    }
177    if lower.starts_with("vs converter data") {
178        return Some(EpcSection::VsConverterData);
179    }
180    if lower.starts_with("z table data") {
181        return Some(EpcSection::ZTableData);
182    }
183    if lower.starts_with("gcd data") {
184        return Some(EpcSection::GcdData);
185    }
186    if lower.starts_with("transaction data") {
187        return Some(EpcSection::TransactionData);
188    }
189    if lower.starts_with("owner data") {
190        return Some(EpcSection::OwnerData);
191    }
192    if lower.starts_with("qtable data") {
193        return Some(EpcSection::QtableData);
194    }
195    if lower.starts_with("ba data") {
196        return Some(EpcSection::BaData);
197    }
198    if lower.starts_with("injgrpelem data") {
199        return Some(EpcSection::InjGrpElemData);
200    }
201    if lower.starts_with("injgroup data") {
202        return Some(EpcSection::InjGroupData);
203    }
204    if lower == "end" {
205        return Some(EpcSection::End);
206    }
207    None
208}
209
210// ---------------------------------------------------------------------------
211// Tokenizer
212// ---------------------------------------------------------------------------
213
214/// Tokenize an EPC record line into space-delimited fields, respecting
215/// double-quoted strings.  Colons `:` are returned as separate tokens.
216fn tokenize_epc(line: &str) -> Vec<String> {
217    let mut tokens = Vec::new();
218    let mut chars = line.chars().peekable();
219    let mut current = String::new();
220
221    while let Some(&ch) = chars.peek() {
222        match ch {
223            '"' => {
224                // Consume entire quoted string (including quotes)
225                chars.next();
226                let mut quoted = String::new();
227                while let Some(&qch) = chars.peek() {
228                    if qch == '"' {
229                        chars.next();
230                        break;
231                    }
232                    quoted.push(qch);
233                    chars.next();
234                }
235                // Flush any pending non-quoted token
236                if !current.is_empty() {
237                    tokens.push(std::mem::take(&mut current));
238                }
239                tokens.push(quoted);
240            }
241            ':' => {
242                if !current.is_empty() {
243                    tokens.push(std::mem::take(&mut current));
244                }
245                tokens.push(":".into());
246                chars.next();
247            }
248            ' ' | '\t' => {
249                if !current.is_empty() {
250                    tokens.push(std::mem::take(&mut current));
251                }
252                chars.next();
253            }
254            _ => {
255                current.push(ch);
256                chars.next();
257            }
258        }
259    }
260    if !current.is_empty() {
261        tokens.push(current);
262    }
263    tokens
264}
265
266/// Parse a token as f64.  Returns 0.0 for empty strings.
267fn parse_f64(token: &str, line: usize, field: &str) -> Result<f64, EpcError> {
268    let s = token.trim().trim_matches('"');
269    if s.is_empty() {
270        return Ok(0.0);
271    }
272    let v: f64 = s.parse().map_err(|_| EpcError::Parse {
273        line,
274        message: format!("cannot parse '{s}' as f64 for field '{field}'"),
275    })?;
276    if !v.is_finite() {
277        return Err(EpcError::NonFiniteValue {
278            line,
279            message: format!("non-finite value {v} for field '{field}'"),
280        });
281    }
282    Ok(v)
283}
284
285/// Parse a token as u32.
286fn parse_u32(token: &str, line: usize, field: &str) -> Result<u32, EpcError> {
287    let s = token.trim().trim_matches('"');
288    if s.is_empty() {
289        return Ok(0);
290    }
291    // Handle floats like "1.0" by parsing as f64 first
292    if s.contains('.') || s.contains('E') || s.contains('e') {
293        let v = parse_f64(s, line, field)?;
294        return Ok(v as u32);
295    }
296    s.parse().map_err(|_| EpcError::Parse {
297        line,
298        message: format!("cannot parse '{s}' as u32 for field '{field}'"),
299    })
300}
301
302/// Parse a token as i32.
303fn parse_i32(token: &str, line: usize, field: &str) -> Result<i32, EpcError> {
304    let s = token.trim().trim_matches('"');
305    if s.is_empty() {
306        return Ok(0);
307    }
308    if s.contains('.') || s.contains('E') || s.contains('e') {
309        let v = parse_f64(s, line, field)?;
310        return Ok(v as i32);
311    }
312    s.parse().map_err(|_| EpcError::Parse {
313        line,
314        message: format!("cannot parse '{s}' as i32 for field '{field}'"),
315    })
316}
317
318/// Get token at index, returning empty string if out of bounds.
319fn tok(tokens: &[String], idx: usize) -> &str {
320    tokens.get(idx).map(|s| s.as_str()).unwrap_or("")
321}
322
323// ---------------------------------------------------------------------------
324// Line preprocessing
325// ---------------------------------------------------------------------------
326
327/// A logical line: the original line(s) joined by `/` continuations,
328/// paired with the original (first) line number.
329struct LogicalLine {
330    text: String,
331    line_num: usize,
332}
333
334/// Preprocess raw lines: join continuation lines (ending with `/`),
335/// skip comment/annotation lines (starting with `@`), and skip blank lines.
336fn preprocess_lines(raw_lines: &[&str]) -> Vec<LogicalLine> {
337    let mut result = Vec::new();
338    let mut i = 0;
339    while i < raw_lines.len() {
340        let line = raw_lines[i];
341        let trimmed = line.trim();
342
343        // Skip annotation lines
344        if trimmed.starts_with('@') {
345            i += 1;
346            continue;
347        }
348
349        // Skip blank lines
350        if trimmed.is_empty() {
351            i += 1;
352            continue;
353        }
354
355        // Check for continuation
356        if let Some(prefix) = trimmed.strip_suffix('/') {
357            let first_line = i + 1; // 1-indexed
358            let mut joined = prefix.to_string();
359            i += 1;
360            while i < raw_lines.len() {
361                let next = raw_lines[i].trim();
362                if let Some(next_prefix) = next.strip_suffix('/') {
363                    joined.push(' ');
364                    joined.push_str(next_prefix);
365                    i += 1;
366                } else {
367                    joined.push(' ');
368                    joined.push_str(next);
369                    i += 1;
370                    break;
371                }
372            }
373            result.push(LogicalLine {
374                text: joined,
375                line_num: first_line,
376            });
377        } else {
378            result.push(LogicalLine {
379                text: trimmed.to_string(),
380                line_num: i + 1,
381            });
382            i += 1;
383        }
384    }
385    result
386}
387
388/// Parse the section header's record count from `[N]`.
389#[cfg(test)]
390fn parse_section_count(header: &str) -> Option<usize> {
391    let start = header.find('[')?;
392    let end = header.find(']')?;
393    if end <= start {
394        return None;
395    }
396    header[start + 1..end].trim().parse().ok()
397}
398
399// ---------------------------------------------------------------------------
400// Split on colon separator
401// ---------------------------------------------------------------------------
402
403/// Split tokens into (before_colon, after_colon).
404/// EPC records use `:` as a separator between identity fields and data fields.
405fn split_on_colon(tokens: &[String]) -> (Vec<String>, Vec<String>) {
406    if let Some(pos) = tokens.iter().position(|t| t == ":") {
407        let before = tokens[..pos].to_vec();
408        let after = if pos + 1 < tokens.len() {
409            tokens[pos + 1..].to_vec()
410        } else {
411            Vec::new()
412        };
413        (before, after)
414    } else {
415        // No colon — treat all tokens as data
416        (Vec::new(), tokens.to_vec())
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Main parse function
422// ---------------------------------------------------------------------------
423
424fn parse_string_with_name(content: &str, name: &str) -> Result<Network, EpcError> {
425    let raw_lines: Vec<&str> = content.lines().collect();
426    let lines = preprocess_lines(&raw_lines);
427
428    let mut network = Network::new(name);
429    network.base_mva = 100.0; // default
430
431    let mut raw_loads: Vec<RawLoad> = Vec::new();
432    let mut raw_shunts: Vec<RawShunt> = Vec::new();
433    let mut bus_vsched: HashMap<u32, f64> = HashMap::new();
434
435    let mut pos = 0;
436
437    while pos < lines.len() {
438        let line = &lines[pos].text;
439
440        if let Some(section) = detect_section(line) {
441            pos += 1; // skip section header line
442
443            match section {
444                EpcSection::Title | EpcSection::Comments => {
445                    // Skip until `!` terminator or next section
446                    while pos < lines.len() {
447                        if lines[pos].text.trim() == "!" {
448                            pos += 1;
449                            break;
450                        }
451                        if detect_section(&lines[pos].text).is_some() {
452                            break;
453                        }
454                        pos += 1;
455                    }
456                }
457                EpcSection::SolutionParameters => {
458                    while pos < lines.len() {
459                        let sp_line = &lines[pos].text;
460                        if sp_line.trim() == "!" {
461                            pos += 1;
462                            break;
463                        }
464                        if detect_section(sp_line).is_some() {
465                            break;
466                        }
467                        // Extract sbase
468                        let lower = sp_line.trim().to_ascii_lowercase();
469                        if lower.starts_with("sbase") {
470                            let parts: Vec<&str> = sp_line.split_whitespace().collect();
471                            if parts.len() >= 2
472                                && let Ok(v) = parts[1].parse::<f64>()
473                            {
474                                network.base_mva = v;
475                            }
476                        }
477                        pos += 1;
478                    }
479                }
480                EpcSection::SubstationData => {
481                    pos = skip_section(&lines, pos);
482                }
483                EpcSection::BusData => {
484                    let (buses, vsched_map, next) = parse_bus_section(&lines, pos)?;
485                    network.buses = buses;
486                    bus_vsched = vsched_map;
487                    pos = next;
488                }
489                EpcSection::BranchData => {
490                    let (branches, next) = parse_branch_section(&lines, pos)?;
491                    network.branches.extend(branches);
492                    pos = next;
493                }
494                EpcSection::TransformerData => {
495                    let (xfmr_branches, next) =
496                        parse_transformer_section(&lines, pos, &network.buses, network.base_mva)?;
497                    network.branches.extend(xfmr_branches);
498                    pos = next;
499                }
500                EpcSection::GeneratorData => {
501                    let (gens, next) = parse_generator_section(&lines, pos, &bus_vsched)?;
502                    network.generators = gens;
503                    pos = next;
504                }
505                EpcSection::LoadData => {
506                    let (loads, next) = parse_load_section(&lines, pos)?;
507                    raw_loads.extend(loads);
508                    pos = next;
509                }
510                EpcSection::ShuntData => {
511                    let (shunts, next) = parse_shunt_section(&lines, pos)?;
512                    raw_shunts.extend(shunts);
513                    pos = next;
514                }
515                EpcSection::SvdData => {
516                    let (shunts, next) = parse_svd_section(&lines, pos, network.base_mva)?;
517                    raw_shunts.extend(shunts);
518                    pos = next;
519                }
520                EpcSection::AreaData => {
521                    let (areas, next) = parse_area_section(&lines, pos);
522                    network.area_schedules = areas;
523                    pos = next;
524                }
525                EpcSection::End => break,
526                _ => {
527                    // Skip unsupported sections
528                    pos = skip_section(&lines, pos);
529                }
530            }
531        } else {
532            pos += 1;
533        }
534    }
535
536    // Post-parse fixups
537    apply_loads(&mut network, &raw_loads).map_err(|err| EpcError::Parse {
538        line: 1,
539        message: err.to_string(),
540    })?;
541    apply_shunts(&mut network, &raw_shunts);
542    fixup_bus_types(&mut network);
543    fixup_voltage_limits(&mut network);
544    fixup_latlon(&mut network);
545    Ok(network)
546}
547
548/// Skip to the next section (used for sections we don't parse).
549fn skip_section(lines: &[LogicalLine], start: usize) -> usize {
550    let mut pos = start;
551    while pos < lines.len() {
552        if detect_section(&lines[pos].text).is_some() {
553            return pos;
554        }
555        pos += 1;
556    }
557    pos
558}
559
560// ---------------------------------------------------------------------------
561// Bus Data
562// ---------------------------------------------------------------------------
563
564/// Parse the bus data section.
565///
566/// EPC bus record (after joining continuations):
567///   `number "name" basekv "?" type_code : ty vsched volt angle ar zone vmax vmin
568///    date_in date_out pid L own st latitude longitude ... subst_no "subst_name" ...`
569///
570/// Bus type field `ty`: 1=PQ, 2=PV, 3=Slack.
571/// Status field `st`: 0=in-service (inverted vs branches).
572#[allow(clippy::type_complexity)]
573fn parse_bus_section(
574    lines: &[LogicalLine],
575    start: usize,
576) -> Result<(Vec<Bus>, HashMap<u32, f64>, usize), EpcError> {
577    let mut buses = Vec::new();
578    let mut bus_vsched: HashMap<u32, f64> = HashMap::new();
579    let mut pos = start;
580
581    while pos < lines.len() {
582        if detect_section(&lines[pos].text).is_some() {
583            return Ok((buses, bus_vsched, pos));
584        }
585
586        let line_num = lines[pos].line_num;
587        let tokens = tokenize_epc(&lines[pos].text);
588        if tokens.is_empty() {
589            pos += 1;
590            continue;
591        }
592
593        let (ident, data) = split_on_colon(&tokens);
594        if ident.len() < 3 || data.len() < 14 {
595            pos += 1;
596            continue;
597        }
598
599        let number = parse_u32(tok(&ident, 0), line_num, "bus")?;
600        let name = ident.get(1).cloned().unwrap_or_default();
601        let base_kv = parse_f64(tok(&ident, 2), line_num, "basekv")?;
602
603        // Data fields after colon:
604        // 0:ty, 1:vsched, 2:volt, 3:angle, 4:ar, 5:zone, 6:vmax, 7:vmin,
605        // 8:date_in, 9:date_out, 10:pid, 11:L, 12:own, 13:st, 14:latitude, 15:longitude
606        let ty = parse_i32(tok(&data, 0), line_num, "ty")?;
607        let vsched = parse_f64(tok(&data, 1), line_num, "vsched")?;
608        let volt = parse_f64(tok(&data, 2), line_num, "volt")?;
609        let angle_deg = parse_f64(tok(&data, 3), line_num, "angle")?;
610        let area = parse_u32(tok(&data, 4), line_num, "ar")?;
611        let zone = parse_u32(tok(&data, 5), line_num, "zone")?;
612        let vmax = parse_f64(tok(&data, 6), line_num, "vmax")?;
613        let vmin = parse_f64(tok(&data, 7), line_num, "vmin")?;
614        // data[8..9] = date_in, date_out (skip)
615        // data[10] = pid, data[11] = L (skip)
616        // data[12] = own (skip)
617        let st = parse_i32(tok(&data, 13), line_num, "st")?;
618        let lat = if data.len() > 14 {
619            parse_f64(tok(&data, 14), line_num, "latitude").unwrap_or(0.0)
620        } else {
621            0.0
622        };
623        let lon = if data.len() > 15 {
624            parse_f64(tok(&data, 15), line_num, "longitude").unwrap_or(0.0)
625        } else {
626            0.0
627        };
628
629        let bus_type = match ty {
630            2 => BusType::PV,
631            3 => BusType::Slack,
632            4 => BusType::Isolated,
633            _ => BusType::PQ, // ty=0 or ty=1 → PQ
634        };
635
636        // EPC bus status: 0=in-service, nonzero=out-of-service
637        let in_service = st == 0;
638        let effective_type = if !in_service {
639            BusType::Isolated
640        } else {
641            bus_type
642        };
643
644        let vm = if volt > 0.0 { volt } else { 1.0 };
645
646        let mut bus = Bus::new(number, effective_type, base_kv);
647        bus.name = name;
648        bus.voltage_magnitude_pu = vm;
649        bus.voltage_angle_rad = angle_deg.to_radians();
650        bus.area = if area > 0 { area } else { 1 };
651        bus.zone = if zone > 0 { zone } else { 1 };
652        bus.voltage_max_pu = vmax;
653        bus.voltage_min_pu = vmin;
654        bus.latitude = Some(lat);
655        bus.longitude = Some(lon);
656
657        // Store vsched for generator voltage setpoint lookup
658        let vs = if vsched > 0.0 { vsched } else { vm };
659        bus_vsched.insert(number, vs);
660
661        buses.push(bus);
662        pos += 1;
663    }
664
665    Ok((buses, bus_vsched, pos))
666}
667
668// ---------------------------------------------------------------------------
669// Branch Data
670// ---------------------------------------------------------------------------
671
672/// Parse the branch data section.
673///
674/// EPC branch record (2-line, joined by `/` continuation):
675///   `from_bus "from_name" from_kv  to_bus "to_name" to_kv  "ck" se "long_id"
676///    : st resist react charge rate1 rate2 rate3 rate4 aloss lngth
677///    [continuation data: owner info, dates, etc.]`
678///
679/// Impedances are in per-unit on system base (unless ohmic flag is set).
680/// Status: 1=in-service, 0=out-of-service (standard convention).
681fn parse_branch_section(
682    lines: &[LogicalLine],
683    start: usize,
684) -> Result<(Vec<Branch>, usize), EpcError> {
685    let mut branches = Vec::new();
686    let mut pos = start;
687
688    while pos < lines.len() {
689        if detect_section(&lines[pos].text).is_some() {
690            return Ok((branches, pos));
691        }
692
693        let line_num = lines[pos].line_num;
694        let tokens = tokenize_epc(&lines[pos].text);
695        if tokens.is_empty() {
696            pos += 1;
697            continue;
698        }
699
700        let (ident, data) = split_on_colon(&tokens);
701        if ident.len() < 7 || data.len() < 6 {
702            pos += 1;
703            continue;
704        }
705
706        // Identity: from_bus(0), from_name(1), from_kv(2), to_bus(3), to_name(4),
707        //           to_kv(5), ck(6), se(7), long_id(8)
708        let from_bus = parse_u32(tok(&ident, 0), line_num, "from_bus")?;
709        let to_bus = parse_u32(tok(&ident, 3), line_num, "to_bus")?;
710        let ck_str = ident.get(6).cloned().unwrap_or_default();
711        let ck = ck_str.trim().parse::<u32>().unwrap_or(1);
712        let se = if ident.len() > 7 {
713            parse_u32(tok(&ident, 7), line_num, "se").unwrap_or(1)
714        } else {
715            1
716        };
717
718        // Encode circuit = ck for section 1, or ck*100+se for multi-section lines
719        let circuit = if se > 1 {
720            format!("{}", ck * 100 + se)
721        } else {
722            ck.to_string()
723        };
724
725        // Data after colon:
726        // 0:st, 1:resist, 2:react, 3:charge, 4:rate1, 5:rate2, 6:rate3, 7:rate4, 8:aloss, 9:lngth
727        let st = parse_i32(tok(&data, 0), line_num, "st")?;
728        let r = parse_f64(tok(&data, 1), line_num, "resist")?;
729        let x = parse_f64(tok(&data, 2), line_num, "react")?;
730        let b = parse_f64(tok(&data, 3), line_num, "charge")?;
731        let rate_a = parse_f64(tok(&data, 4), line_num, "rate1")?;
732        let rate_b = parse_f64(tok(&data, 5), line_num, "rate2").unwrap_or(0.0);
733        let rate_c = parse_f64(tok(&data, 6), line_num, "rate3").unwrap_or(0.0);
734
735        let in_service = st == 1;
736
737        let mut branch = Branch::new_line(from_bus, to_bus, r, x, b);
738        branch.circuit = circuit;
739        branch.rating_a_mva = rate_a;
740        branch.rating_b_mva = rate_b;
741        branch.rating_c_mva = rate_c;
742        branch.in_service = in_service;
743
744        branches.push(branch);
745        pos += 1;
746    }
747
748    Ok((branches, pos))
749}
750
751// ---------------------------------------------------------------------------
752// Transformer Data
753// ---------------------------------------------------------------------------
754
755/// Parse the transformer data section.
756///
757/// EPC transformer record (originally 4 lines, joined by `/` continuations into
758/// 1 logical line):
759///   Line 1: `from_bus "from_name" from_kv  to_bus "to_name" to_kv  "ck" "long_id"
760///            : st ty  reg_bus "reg_name" reg_kv  zt  int_bus "int_name" int_kv
761///              tert_bus "tert_name" tert_kv  ar zone  tbase  ps_r  ps_x  pt_r  pt_x  ts_r  ts_x`
762///   Line 2 (continuation): `kv_primary  kv_secondary  ...  rate1  rate2  rate3  rate4  ...`
763///   Lines 3-4 (continuation): owner info, more metadata
764///
765/// After joining, all 4 lines become one logical line.
766fn parse_transformer_section(
767    lines: &[LogicalLine],
768    start: usize,
769    buses: &[Bus],
770    base_mva: f64,
771) -> Result<(Vec<Branch>, usize), EpcError> {
772    let mut branches = Vec::new();
773    let mut pos = start;
774
775    // Build bus base kV lookup
776    let bus_basekv: HashMap<u32, f64> = buses.iter().map(|b| (b.number, b.base_kv)).collect();
777
778    while pos < lines.len() {
779        if detect_section(&lines[pos].text).is_some() {
780            return Ok((branches, pos));
781        }
782
783        let line_num = lines[pos].line_num;
784        let tokens = tokenize_epc(&lines[pos].text);
785        if tokens.is_empty() {
786            pos += 1;
787            continue;
788        }
789
790        let (ident, data) = split_on_colon(&tokens);
791        if ident.len() < 7 || data.len() < 20 {
792            pos += 1;
793            continue;
794        }
795
796        // Identity: from_bus(0), from_name(1), from_kv(2), to_bus(3), to_name(4),
797        //           to_kv(5), ck(6), long_id(7)
798        let from_bus = parse_u32(tok(&ident, 0), line_num, "from_bus")?;
799        let to_bus = parse_u32(tok(&ident, 3), line_num, "to_bus")?;
800        let ck_str = ident.get(6).cloned().unwrap_or_default();
801        let ck = ck_str.trim().parse::<u32>().unwrap_or(1);
802
803        // Data after colon (all 4 lines joined):
804        // 0:st, 1:ty, 2:reg_bus, 3:reg_name, 4:reg_kv, 5:zt, 6:int_bus, 7:int_name,
805        // 8:int_kv, 9:tert_bus, 10:tert_name, 11:tert_kv, 12:ar, 13:zone,
806        // 14:tbase, 15:ps_r, 16:ps_x, 17:pt_r, 18:pt_x, 19:ts_r, 20:ts_x
807        // Then continuation data: kv_primary, kv_secondary, ...
808        let st = parse_i32(tok(&data, 0), line_num, "st")?;
809        let _ty = parse_i32(tok(&data, 1), line_num, "ty")?;
810
811        // Find tbase, ps_r, ps_x — they come after reg/int/tert bus info.
812        // The reg/int/tert each take 3 tokens (bus_no, "name", kv), but in the
813        // tokenized form, we need to count carefully.
814        //
815        // After st(0), ty(1), the regulated bus info starts at index 2.
816        // Each bus reference = bus_no(int) + "name"(string) + kv(float) = 3 tokens
817        // Regulated: data[2..5], Int: data[5..8], Tert: data[8..11] (if zt > 0)
818        // But the actual positions vary because name is a quoted string which is one token.
819        //
820        // Strategy: scan backwards from the continuation data which starts with
821        // kv_primary (a float like 115.0 or 230.0). Or better: search for tbase
822        // using the pattern of consecutive scientific-notation values (ps_r, ps_x).
823
824        // Simpler approach: find area/zone/tbase by scanning for the pattern
825        // where we get scientific notation values (like 1.000000E-04).
826        // The tbase, ps_r, ps_x, pt_r, pt_x, ts_r, ts_x are the 7 values
827        // just before the continuation data.
828        //
829        // Let's find them by looking for scientific notation pattern in data tokens.
830        let mut tbase_idx = None;
831        for i in 10..data.len().saturating_sub(6) {
832            // Look for tbase pattern: a larger number followed by E-notation values
833            let s = tok(&data, i);
834            if (s.contains("E-") || s.contains("E+") || s.contains("e-") || s.contains("e+"))
835                && i > 0
836            {
837                // The token before the first E-notation is likely tbase
838                tbase_idx = Some(i - 1);
839                break;
840            }
841        }
842
843        // Fallback: search for the area/zone pair (two small integers before tbase)
844        if tbase_idx.is_none() {
845            // Try another pattern: look for area/zone as small integers before a 100.0 value
846            for i in 10..data.len().saturating_sub(4) {
847                let val = parse_f64(tok(&data, i), line_num, "tbase_scan").unwrap_or(0.0);
848                if (val - 100.0).abs() < 1.0 || val > 50.0 {
849                    // Check if preceded by two small integers (area, zone)
850                    let a = parse_u32(tok(&data, i.wrapping_sub(2)), line_num, "area_scan")
851                        .unwrap_or(999);
852                    let z = parse_u32(tok(&data, i.wrapping_sub(1)), line_num, "zone_scan")
853                        .unwrap_or(999);
854                    if a < 100 && z < 100 {
855                        tbase_idx = Some(i);
856                        break;
857                    }
858                }
859            }
860        }
861
862        let (tbase, ps_r, ps_x) = if let Some(ti) = tbase_idx {
863            let tbase = parse_f64(tok(&data, ti), line_num, "tbase")?;
864            let ps_r = parse_f64(tok(&data, ti + 1), line_num, "ps_r")?;
865            let ps_x = parse_f64(tok(&data, ti + 2), line_num, "ps_x")?;
866            (tbase, ps_r, ps_x)
867        } else {
868            // Cannot find tbase — skip this record
869            pos += 1;
870            continue;
871        };
872
873        // After tbase+6 (ps_r, ps_x, pt_r, pt_x, ts_r, ts_x), the continuation
874        // data starts with kv_primary, kv_secondary.
875        let cont_start = tbase_idx.expect("tbase_idx guaranteed Some by prior branch") + 7; // after ts_x
876
877        let kv_primary = if data.len() > cont_start {
878            parse_f64(tok(&data, cont_start), line_num, "kv_primary")?
879        } else {
880            0.0
881        };
882        let kv_secondary = if data.len() > cont_start + 1 {
883            parse_f64(tok(&data, cont_start + 1), line_num, "kv_secondary")?
884        } else {
885            0.0
886        };
887
888        // Ratings: rate1 is at cont_start+6, rate2 at +7, rate3 at +8
889        let rate_a = if data.len() > cont_start + 6 {
890            parse_f64(tok(&data, cont_start + 6), line_num, "rate1").unwrap_or(0.0)
891        } else {
892            0.0
893        };
894        let rate_b = if data.len() > cont_start + 7 {
895            parse_f64(tok(&data, cont_start + 7), line_num, "rate2").unwrap_or(0.0)
896        } else {
897            0.0
898        };
899        let rate_c = if data.len() > cont_start + 8 {
900            parse_f64(tok(&data, cont_start + 8), line_num, "rate3").unwrap_or(0.0)
901        } else {
902            0.0
903        };
904
905        // Convert impedance from transformer base to system base
906        let r = if tbase > 0.0 && (tbase - base_mva).abs() > 1e-6 {
907            ps_r * base_mva / tbase
908        } else {
909            ps_r
910        };
911        let x = if tbase > 0.0 && (tbase - base_mva).abs() > 1e-6 {
912            ps_x * base_mva / tbase
913        } else {
914            ps_x
915        };
916
917        // Compute tap ratio
918        let from_basekv = bus_basekv.get(&from_bus).copied().unwrap_or(kv_primary);
919        let to_basekv = bus_basekv.get(&to_bus).copied().unwrap_or(kv_secondary);
920
921        let tap = if kv_primary > 0.0 && kv_secondary > 0.0 && from_basekv > 0.0 && to_basekv > 0.0
922        {
923            (kv_primary / from_basekv) / (kv_secondary / to_basekv)
924        } else {
925            1.0
926        };
927
928        let in_service = st == 1;
929
930        let mut branch = Branch::new_line(from_bus, to_bus, r, x, 0.0);
931        branch.circuit = ck.to_string();
932        branch.tap = tap;
933        branch.rating_a_mva = rate_a;
934        branch.rating_b_mva = rate_b;
935        branch.rating_c_mva = rate_c;
936        branch.in_service = in_service;
937        branch.branch_type = BranchType::Transformer;
938
939        branches.push(branch);
940        pos += 1;
941    }
942
943    Ok((branches, pos))
944}
945
946// ---------------------------------------------------------------------------
947// Generator Data
948// ---------------------------------------------------------------------------
949
950/// Parse the generator data section.
951///
952/// EPC generator record (2-line, joined by `/` continuation):
953///   `bus "name" basekv "id" "long_id" : st  reg_bus "reg_name" reg_kv
954///    prf qrf  ar zone  pgen pmax pmin  qgen qmax qmin  mbase cmp_r cmp_x gen_r gen_x
955///    hbus "hname" hkv  tbus "tname" tkv  date_in date_out pid N
956///    [continuation data]`
957///
958/// Voltage setpoint comes from the bus `vsched` field, not the solved `volt`.
959/// AVR status: if (qmax - qmin) <= 2.0 MVAr, AVR is assumed off.
960fn parse_generator_section(
961    lines: &[LogicalLine],
962    start: usize,
963    bus_vsched: &HashMap<u32, f64>,
964) -> Result<(Vec<Generator>, usize), EpcError> {
965    let mut generators = Vec::new();
966    let mut pos = start;
967
968    while pos < lines.len() {
969        if detect_section(&lines[pos].text).is_some() {
970            return Ok((generators, pos));
971        }
972
973        let line_num = lines[pos].line_num;
974        let tokens = tokenize_epc(&lines[pos].text);
975        if tokens.is_empty() {
976            pos += 1;
977            continue;
978        }
979
980        let (ident, data) = split_on_colon(&tokens);
981        if ident.len() < 4 || data.len() < 18 {
982            pos += 1;
983            continue;
984        }
985
986        // Identity: bus(0), name(1), basekv(2), id(3), long_id(4)
987        let bus = parse_u32(tok(&ident, 0), line_num, "gen_bus")?;
988        let gen_id = ident.get(3).cloned().unwrap_or_default();
989
990        // Data after colon:
991        // 0:st, then reg_bus(1), reg_name(2), reg_kv(3)
992        // Then: prf(4), qrf(5), ar(6), zone(7)
993        // pgen(8), pmax(9), pmin(10), qgen(11), qmax(12), qmin(13), mbase(14)
994        // cmp_r(15), cmp_x(16), gen_r(17), gen_x(18)
995
996        let st = parse_i32(tok(&data, 0), line_num, "st")?;
997
998        // reg_bus takes 3 tokens (bus_no, "name", kv)
999        // Data layout after st: reg_bus_no(1), reg_name(2), reg_kv(3)
1000        // prf(4), qrf(5), ar(6), zone(7)
1001        // pgen(8), pmax(9), pmin(10), qgen(11), qmax(12), qmin(13), mbase(14)
1002
1003        let pgen = parse_f64(tok(&data, 8), line_num, "pgen")?;
1004        let pmax = parse_f64(tok(&data, 9), line_num, "pmax")?;
1005        let pmin = parse_f64(tok(&data, 10), line_num, "pmin")?;
1006        let qgen = parse_f64(tok(&data, 11), line_num, "qgen")?;
1007        let qmax = parse_f64(tok(&data, 12), line_num, "qmax")?;
1008        let qmin = parse_f64(tok(&data, 13), line_num, "qmin")?;
1009        let mbase = parse_f64(tok(&data, 14), line_num, "mbase")?;
1010
1011        let in_service = st == 1;
1012
1013        // Voltage setpoint from bus vsched (not solved volt)
1014        let vs = bus_vsched.get(&bus).copied().unwrap_or(1.0);
1015
1016        let mut generator = Generator::new(bus, pgen, vs);
1017        generator.machine_id = Some(gen_id.trim().to_string());
1018        generator.q = qgen;
1019        generator.qmax = qmax;
1020        generator.qmin = qmin;
1021        generator.pmax = pmax;
1022        generator.pmin = pmin;
1023        generator.machine_base_mva = if mbase > 0.0 { mbase } else { 100.0 };
1024        generator.in_service = in_service;
1025
1026        generators.push(generator);
1027        pos += 1;
1028    }
1029
1030    Ok((generators, pos))
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Load Data
1035// ---------------------------------------------------------------------------
1036
1037/// Parse the load data section.
1038///
1039/// EPC load record (single line):
1040///   `bus "name" basekv "id" "long_id" : st  mw  mvar  mw_i  mvar_i  mw_z  mvar_z  ar zone ...`
1041///
1042/// Only constant-power (mw, mvar) loads are used for power flow.
1043/// Status: 1=in-service, 0=out-of-service.
1044fn parse_load_section(
1045    lines: &[LogicalLine],
1046    start: usize,
1047) -> Result<(Vec<RawLoad>, usize), EpcError> {
1048    let mut loads = Vec::new();
1049    let mut pos = start;
1050
1051    while pos < lines.len() {
1052        if detect_section(&lines[pos].text).is_some() {
1053            return Ok((loads, pos));
1054        }
1055
1056        let line_num = lines[pos].line_num;
1057        let tokens = tokenize_epc(&lines[pos].text);
1058        if tokens.is_empty() {
1059            pos += 1;
1060            continue;
1061        }
1062
1063        let (ident, data) = split_on_colon(&tokens);
1064        if ident.len() < 3 || data.len() < 3 {
1065            pos += 1;
1066            continue;
1067        }
1068
1069        let bus = parse_u32(tok(&ident, 0), line_num, "load_bus")?;
1070
1071        // Data: st(0), mw(1), mvar(2), mw_i(3), mvar_i(4), mw_z(5), mvar_z(6)
1072        let st = parse_i32(tok(&data, 0), line_num, "st")?;
1073        let pl = parse_f64(tok(&data, 1), line_num, "mw")?;
1074        let ql = parse_f64(tok(&data, 2), line_num, "mvar")?;
1075
1076        loads.push(RawLoad {
1077            bus,
1078            id: String::new(),
1079            status: st,
1080            owner: None,
1081            pl,
1082            ql,
1083            conforming: true,
1084            zip_p_impedance_frac: 0.0,
1085            zip_p_current_frac: 0.0,
1086            zip_p_power_frac: 1.0,
1087            zip_q_impedance_frac: 0.0,
1088            zip_q_current_frac: 0.0,
1089            zip_q_power_frac: 1.0,
1090        });
1091        pos += 1;
1092    }
1093
1094    Ok((loads, pos))
1095}
1096
1097// ---------------------------------------------------------------------------
1098// Shunt Data (fixed bus shunts)
1099// ---------------------------------------------------------------------------
1100
1101/// Parse the fixed shunt data section.
1102///
1103/// EPC shunt record:
1104///   `bus "name" basekv "id" ... "ck" se "long_id" : st ar zone pu_mw pu_mvar ...`
1105fn parse_shunt_section(
1106    lines: &[LogicalLine],
1107    start: usize,
1108) -> Result<(Vec<RawShunt>, usize), EpcError> {
1109    let mut shunts = Vec::new();
1110    let mut pos = start;
1111
1112    while pos < lines.len() {
1113        if detect_section(&lines[pos].text).is_some() {
1114            return Ok((shunts, pos));
1115        }
1116
1117        let line_num = lines[pos].line_num;
1118        let tokens = tokenize_epc(&lines[pos].text);
1119        if tokens.is_empty() {
1120            pos += 1;
1121            continue;
1122        }
1123
1124        let (ident, data) = split_on_colon(&tokens);
1125        if ident.len() < 3 || data.len() < 5 {
1126            pos += 1;
1127            continue;
1128        }
1129
1130        let bus = parse_u32(tok(&ident, 0), line_num, "shunt_bus")?;
1131
1132        // Data: st(0), ar(1), zone(2), pu_mw(3), pu_mvar(4)
1133        let st = parse_i32(tok(&data, 0), line_num, "st")?;
1134        let gl = parse_f64(tok(&data, 3), line_num, "pu_mw")?;
1135        let bl = parse_f64(tok(&data, 4), line_num, "pu_mvar")?;
1136
1137        shunts.push(RawShunt {
1138            bus,
1139            status: st,
1140            gl,
1141            bl,
1142        });
1143        pos += 1;
1144    }
1145
1146    Ok((shunts, pos))
1147}
1148
1149// ---------------------------------------------------------------------------
1150// SVD Data (switched shunt devices)
1151// ---------------------------------------------------------------------------
1152
1153/// Parse the SVD (switched shunt device) data section.
1154///
1155/// EPC SVD record (2-line, joined by `/` continuation):
1156///   `bus "name" basekv "id" "long_id" : st ty  reg_bus "reg_name" reg_kv
1157///    ar zone  g b  min_c max_c vband bmin bmax ...`
1158///
1159/// The `b` field is the current operating point susceptance.
1160/// We model switched shunts as fixed at their operating point (same as PSS/E BINIT).
1161fn parse_svd_section(
1162    lines: &[LogicalLine],
1163    start: usize,
1164    base_mva: f64,
1165) -> Result<(Vec<RawShunt>, usize), EpcError> {
1166    let mut shunts = Vec::new();
1167    let mut pos = start;
1168
1169    while pos < lines.len() {
1170        if detect_section(&lines[pos].text).is_some() {
1171            return Ok((shunts, pos));
1172        }
1173
1174        let line_num = lines[pos].line_num;
1175        let tokens = tokenize_epc(&lines[pos].text);
1176        if tokens.is_empty() {
1177            pos += 1;
1178            continue;
1179        }
1180
1181        let (ident, data) = split_on_colon(&tokens);
1182        if ident.len() < 3 || data.len() < 8 {
1183            pos += 1;
1184            continue;
1185        }
1186
1187        let bus = parse_u32(tok(&ident, 0), line_num, "svd_bus")?;
1188
1189        // Data after colon:
1190        // 0:st, 1:ty, then reg_bus(2), reg_name(3), reg_kv(4)
1191        // 5:ar, 6:zone, 7:g, 8:b, 9:min_c, 10:max_c, 11:vband, 12:bmin, 13:bmax
1192        let st = parse_i32(tok(&data, 0), line_num, "st")?;
1193        // reg_bus takes 3 tokens (bus_no, "name", kv) starting at data[2]
1194        // ar(5), zone(6), g(7), b(8)
1195        let g = parse_f64(tok(&data, 7), line_num, "g").unwrap_or(0.0);
1196        let b = parse_f64(tok(&data, 8), line_num, "b").unwrap_or(0.0);
1197
1198        // SVD susceptance is in per-unit on system base (MVAr at V=1.0)
1199        // Convert to MW/MVAr: multiply by base_mva
1200        // Actually, EPC SVD g/b values appear to be in per-unit already
1201        // matching the shunt convention in Network (MW/MVAr at V=1.0)
1202        shunts.push(RawShunt {
1203            bus,
1204            status: st,
1205            gl: g * base_mva,
1206            bl: b * base_mva,
1207        });
1208        pos += 1;
1209    }
1210
1211    Ok((shunts, pos))
1212}
1213
1214// ---------------------------------------------------------------------------
1215// Area Data
1216// ---------------------------------------------------------------------------
1217
1218/// Parse the area data section.
1219///
1220/// EPC area record:
1221///   `number "name" : swing desired tol pnet qnet ...`
1222fn parse_area_section(lines: &[LogicalLine], start: usize) -> (Vec<AreaSchedule>, usize) {
1223    let mut areas = Vec::new();
1224    let mut pos = start;
1225
1226    while pos < lines.len() {
1227        if detect_section(&lines[pos].text).is_some() {
1228            return (areas, pos);
1229        }
1230
1231        let tokens = tokenize_epc(&lines[pos].text);
1232        if tokens.len() < 2 {
1233            pos += 1;
1234            continue;
1235        }
1236
1237        let number = tokens[0].parse::<u32>().unwrap_or(0);
1238        let name = tokens.get(1).cloned().unwrap_or_default();
1239
1240        if number > 0 {
1241            areas.push(AreaSchedule {
1242                number,
1243                name,
1244                ..Default::default()
1245            });
1246        }
1247        pos += 1;
1248    }
1249
1250    (areas, pos)
1251}
1252
1253// ---------------------------------------------------------------------------
1254// Post-parse fixups
1255// ---------------------------------------------------------------------------
1256
1257/// Assign PV/Slack bus types based on generator data.
1258///
1259/// In EPC, bus type may already be set from the `ty` field.  But we also
1260/// cross-check: any bus with an in-service generator that has AVR on
1261/// (qmax - qmin > 2.0 MVAr) should be PV if not already Slack.
1262fn fixup_bus_types(network: &mut Network) {
1263    let gen_bus_set: HashMap<u32, bool> = network
1264        .generators
1265        .iter()
1266        .filter(|g| g.in_service)
1267        .map(|g| {
1268            let avr_on = (g.qmax - g.qmin).abs() > 2.0;
1269            (g.bus, avr_on)
1270        })
1271        .collect();
1272
1273    for bus in &mut network.buses {
1274        if bus.bus_type == BusType::Isolated {
1275            continue;
1276        }
1277        if let Some(&avr_on) = gen_bus_set.get(&bus.number)
1278            && bus.bus_type == BusType::PQ
1279            && avr_on
1280        {
1281            bus.bus_type = BusType::PV;
1282        }
1283    }
1284
1285    // Ensure at least one slack bus exists
1286    let has_slack = network.buses.iter().any(|b| b.bus_type == BusType::Slack);
1287    if !has_slack {
1288        // Find the largest generator bus and make it slack
1289        if let Some(largest_gen) =
1290            network
1291                .generators
1292                .iter()
1293                .filter(|g| g.in_service)
1294                .max_by(|a, b| {
1295                    a.pmax
1296                        .partial_cmp(&b.pmax)
1297                        .unwrap_or(std::cmp::Ordering::Equal)
1298                })
1299        {
1300            let slack_bus = largest_gen.bus;
1301            for bus in &mut network.buses {
1302                if bus.number == slack_bus {
1303                    bus.bus_type = BusType::Slack;
1304                    break;
1305                }
1306            }
1307        }
1308    }
1309}
1310
1311/// Sanitize voltage limits: if they look like kV values, reset to pu defaults.
1312fn fixup_voltage_limits(network: &mut Network) {
1313    for bus in &mut network.buses {
1314        if bus.voltage_max_pu > 10.0 || bus.voltage_min_pu > 10.0 {
1315            bus.voltage_max_pu = 1.1;
1316            bus.voltage_min_pu = 0.9;
1317        }
1318        if bus.voltage_max_pu <= 0.0 {
1319            bus.voltage_max_pu = 1.1;
1320        }
1321        if bus.voltage_min_pu <= 0.0 {
1322            bus.voltage_min_pu = 0.9;
1323        }
1324    }
1325}
1326
1327/// Set lat/lon to None if both are zero (invalid sentinel in EPC).
1328fn fixup_latlon(network: &mut Network) {
1329    for bus in &mut network.buses {
1330        if let (Some(lat), Some(lon)) = (bus.latitude, bus.longitude)
1331            && lat.abs() < 1e-10
1332            && lon.abs() < 1e-10
1333        {
1334            bus.latitude = None;
1335            bus.longitude = None;
1336        }
1337    }
1338}
1339
1340// ---------------------------------------------------------------------------
1341// Tests
1342// ---------------------------------------------------------------------------
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347
1348    #[test]
1349    fn test_tokenize_epc_basic() {
1350        let tokens = tokenize_epc("  1001 115.0000  0.005240  0.035800 ");
1351        assert_eq!(tokens, vec!["1001", "115.0000", "0.005240", "0.035800"]);
1352    }
1353
1354    #[test]
1355    fn test_tokenize_epc_quoted() {
1356        let tokens = tokenize_epc(r#"   1001 "ODESSA 2 0  " 115.0000 " "  0  : "#);
1357        assert_eq!(tokens[0], "1001");
1358        assert_eq!(tokens[1], "ODESSA 2 0  ");
1359        assert_eq!(tokens[2], "115.0000");
1360        assert_eq!(tokens[3], " ");
1361        assert_eq!(tokens[4], "0");
1362        assert_eq!(tokens[5], ":");
1363    }
1364
1365    #[test]
1366    fn test_tokenize_epc_empty() {
1367        assert!(tokenize_epc("").is_empty());
1368        assert!(tokenize_epc("   ").is_empty());
1369    }
1370
1371    #[test]
1372    fn test_detect_section() {
1373        assert_eq!(
1374            detect_section("bus data  [2751]  ty vsched"),
1375            Some(EpcSection::BusData)
1376        );
1377        assert_eq!(
1378            detect_section("branch data  [ 3993]  ck se"),
1379            Some(EpcSection::BranchData)
1380        );
1381        assert_eq!(
1382            detect_section("transformer data  [1351]"),
1383            Some(EpcSection::TransformerData)
1384        );
1385        assert_eq!(
1386            detect_section("generator data  [1099]"),
1387            Some(EpcSection::GeneratorData)
1388        );
1389        assert_eq!(
1390            detect_section("load data  [1410]"),
1391            Some(EpcSection::LoadData)
1392        );
1393        assert_eq!(
1394            detect_section("shunt data  [   0]"),
1395            Some(EpcSection::ShuntData)
1396        );
1397        assert_eq!(
1398            detect_section("svd data  [ 202]"),
1399            Some(EpcSection::SvdData)
1400        );
1401        assert_eq!(
1402            detect_section("area data  [  8]"),
1403            Some(EpcSection::AreaData)
1404        );
1405        assert_eq!(
1406            detect_section("zone data  [   1]"),
1407            Some(EpcSection::ZoneData)
1408        );
1409        assert_eq!(detect_section("end"), Some(EpcSection::End));
1410        assert_eq!(detect_section("title"), Some(EpcSection::Title));
1411        assert_eq!(
1412            detect_section("solution parameters"),
1413            Some(EpcSection::SolutionParameters)
1414        );
1415        assert_eq!(detect_section("   not a section"), None);
1416    }
1417
1418    #[test]
1419    fn test_parse_section_count() {
1420        assert_eq!(
1421            parse_section_count("bus data  [2751]  ty vsched"),
1422            Some(2751)
1423        );
1424        assert_eq!(parse_section_count("shunt data  [   0]"), Some(0));
1425        assert_eq!(parse_section_count("branch data  [ 3993]"), Some(3993));
1426        assert_eq!(parse_section_count("no brackets here"), None);
1427    }
1428
1429    #[test]
1430    fn test_split_on_colon() {
1431        let tokens = tokenize_epc("1001 \"name\" 115.00 : 1 0.005 0.035");
1432        let (before, after) = split_on_colon(&tokens);
1433        assert_eq!(before.len(), 3);
1434        assert_eq!(after.len(), 3);
1435        assert_eq!(before[0], "1001");
1436        assert_eq!(after[0], "1");
1437    }
1438
1439    #[test]
1440    fn test_preprocess_continuation() {
1441        let raw = vec!["first line /", " second line", "third line"];
1442        let lines = preprocess_lines(&raw);
1443        assert_eq!(lines.len(), 2);
1444        assert!(lines[0].text.contains("first line"));
1445        assert!(lines[0].text.contains("second line"));
1446        assert_eq!(lines[1].text, "third line");
1447    }
1448
1449    #[test]
1450    fn test_preprocess_annotations_skip() {
1451        let raw = vec!["@! this is an annotation", "real data line"];
1452        let lines = preprocess_lines(&raw);
1453        assert_eq!(lines.len(), 1);
1454        assert_eq!(lines[0].text, "real data line");
1455    }
1456
1457    #[test]
1458    fn test_parse_f64_edge_cases() {
1459        assert_eq!(parse_f64("", 1, "test").unwrap(), 0.0);
1460        assert_eq!(parse_f64("3.15", 1, "test").unwrap(), 3.15);
1461        assert_eq!(parse_f64("1.000000E-04", 1, "test").unwrap(), 1e-4);
1462        assert_eq!(parse_f64("-17.299999", 1, "test").unwrap(), -17.299999);
1463        assert!(parse_f64("abc", 1, "test").is_err());
1464    }
1465
1466    #[test]
1467    fn test_parse_mini_epc() {
1468        let epc = r#"title
1469!
1470comments
1471!
1472solution parameters
1473sbase 100.0000    system mva base
1474!
1475bus data  [3]               ty  vsched   volt     angle    ar zone  vmax   vmin   date_in date_out pid L own st
1476   1 "BUS 1       " 345.0000 " "  0  :  3 1.060000  1.060000   0.000000    1    1 1.1000 0.9000 19400101 21991231   0 0   1 0
1477   2 "BUS 2       " 345.0000 " "  0  :  2 1.045000  1.045000 -10.000000    1    1 1.1000 0.9000 19400101 21991231   0 0   1 0
1478   3 "BUS 3       " 345.0000 " "  0  :  1 1.000000  1.007000 -15.000000    1    1 1.1000 0.9000 19400101 21991231   0 0   1 0
1479branch data  [ 1]                                          ck  se  long_id    st resist   react   charge   rate1  rate2  rate3  rate4 aloss  lngth
1480   1 "BUS 1       " 345.00    3 "BUS 3       " 345.00  "1 "   1 " "  :  1  0.010000  0.100000  0.020000  250.0    0.0    0.0    0.0 0.000    0.0  1    1  0.000000  0.000000  1.000000 19400101 21991231   0 1
1481transformer data  [1]                                     ck   long_id     st ty
1482   1 "BUS 1       " 345.00    2 "BUS 2       " 345.00  "1 "   " " :   1  1       0 "            "   0.00  0       0 "            "   0.00       0 "            "   0.00    1    1 100.000000 5.000000E-03 5.000000E-02 0.000000E+00 0.000000E+00 0.000000E+00 0.000000E+00  345.000000 345.000000  0.000000  0.000000 0.000000E+00 0.000000E+00  200.0  200.0  200.0    0.0 0.000  1.500000  0.510000  1.500000  0.510000 -0.006250  1.000000  1.000000  1.000000  1.000000 19400101 21991231   0 1     0.0    0.0    0.0    0.0    1 1.000   0 0.000   0 0.000   0 0.000   0 0.000   0 0.000   0 0.000   0 0.000  0    0.000000   0.000000  0.000000  0.000000     0.0    0.0    0.0     0.0    0.0    0.0 0.000 0.000  1  1  1 0.0000 0.0000 0  0  0 0.000000 0.000000  0.000000  0.000000  " "
1483generator data  [1]         id   long_id    st
1484   1 "BUS 1       " 345.00 "1 "  " " :  1    1 "BUS 1       " 345.00  1.000000  1.000000   1    1 100.000000 200.000000   0.000000 10.000000 50.000000 -30.000000 200.0000 0.000 0.000 0.000 1.000      -1 "            "   0.00      -1 "            "   0.00  19400101 21991231   0 0  0.0000 0.0000 1.0000    1 1.000   0 0.000   0 0.000   0 0.000  " "
1485load data  [1]             id   long_id     st      mw      mvar
1486   3 "BUS 3       " 345.00 "1 "   " "  :  1 150.000000 50.000000 0.000000 0.000000 0.000000 0.000000   1    1 19400101 21991231   0 0   1 0
1487area data  [  1]
1488    1 "Area 1                          "       0    0.000    1.000 0.0 0.0 0  0  0  " "  0.000000
1489zone data  [   1]
1490    1 "Zone 1                          "    0.000   0.000 0
1491end
1492"#;
1493
1494        let net = parse_str(epc).expect("failed to parse mini EPC");
1495        assert_eq!(net.n_buses(), 3, "expected 3 buses");
1496        assert_eq!(
1497            net.branches.len(),
1498            2,
1499            "expected 2 branches (1 line + 1 xfmr)"
1500        );
1501        assert_eq!(net.generators.len(), 1, "expected 1 generator");
1502
1503        // Bus types
1504        assert_eq!(net.buses[0].bus_type, BusType::Slack);
1505        assert_eq!(net.buses[1].bus_type, BusType::PV);
1506        assert_eq!(net.buses[2].bus_type, BusType::PQ);
1507
1508        // Load accumulated to bus 3 (via Load objects)
1509        let bus_pd = net.bus_load_p_mw();
1510        let bus_qd = net.bus_load_q_mvar();
1511        assert!((bus_pd[2] - 150.0).abs() < 0.01);
1512        assert!((bus_qd[2] - 50.0).abs() < 0.01);
1513
1514        // Generator data
1515        assert_eq!(net.generators[0].bus, 1);
1516        assert!((net.generators[0].p - 100.0).abs() < 0.01);
1517        assert!((net.generators[0].pmax - 200.0).abs() < 0.01);
1518
1519        // Branch impedance
1520        let line = &net.branches[0];
1521        assert!((line.r - 0.01).abs() < 1e-6);
1522        assert!((line.x - 0.1).abs() < 1e-6);
1523        assert!((line.b - 0.02).abs() < 1e-6);
1524
1525        // Transformer
1526        let xfmr = &net.branches[1];
1527        assert!((xfmr.r - 0.005).abs() < 1e-6);
1528        assert!((xfmr.x - 0.05).abs() < 1e-6);
1529        assert!((xfmr.tap - 1.0).abs() < 1e-6); // nominal tap (same basekv)
1530
1531        // Areas
1532        assert_eq!(net.area_schedules.len(), 1);
1533    }
1534
1535    #[test]
1536    fn test_parse_texas2k_epc() {
1537        // Integration test with real EPC file
1538        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
1539        let path = std::path::PathBuf::from(&manifest)
1540            .join("../..")
1541            .join("tests/data/epc/Texas2k.epc");
1542        if !path.exists() {
1543            return; // skip if data not available
1544        }
1545
1546        let net = parse_file(&path).expect("failed to parse Texas2k EPC");
1547
1548        // Verify structural metrics from the file header counts
1549        assert_eq!(net.n_buses(), 2751, "expected 2751 buses");
1550        // Branch + transformer count: 3993 branches + 1351 transformers = 5344
1551        assert!(
1552            net.branches.len() > 4000,
1553            "expected >4000 branches, got {}",
1554            net.branches.len()
1555        );
1556        // Generator count from header: 1099
1557        assert!(
1558            net.generators.len() > 1000,
1559            "expected >1000 generators, got {}",
1560            net.generators.len()
1561        );
1562
1563        // Verify base_mva
1564        assert!((net.base_mva - 100.0).abs() < 0.01);
1565
1566        // Verify there is at least one slack bus
1567        let slack_count = net
1568            .buses
1569            .iter()
1570            .filter(|b| b.bus_type == BusType::Slack)
1571            .count();
1572        assert!(slack_count >= 1, "no slack bus found");
1573
1574        // Verify total load is reasonable (ERCOT-scale: ~60-80 GW)
1575        let total_load: f64 = net.total_load_mw();
1576        assert!(
1577            total_load > 10000.0,
1578            "total load too low: {total_load:.0} MW"
1579        );
1580
1581        // Verify areas parsed
1582        assert_eq!(net.area_schedules.len(), 8, "expected 8 areas");
1583    }
1584
1585    #[test]
1586    fn test_parse_texas7k_epc() {
1587        // Integration test with real Texas7k EPC file
1588        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
1589        let path = std::path::PathBuf::from(&manifest)
1590            .join("../..")
1591            .join("tests/data/epc/Texas7k.epc");
1592        if !path.exists() {
1593            return; // skip if data not available
1594        }
1595
1596        let net = parse_file(&path).expect("failed to parse Texas7k EPC");
1597
1598        // Verify structural metrics
1599        assert!(
1600            net.n_buses() > 6000,
1601            "expected >6000 buses, got {}",
1602            net.n_buses()
1603        );
1604        assert!(
1605            net.branches.len() > 7000,
1606            "expected >7000 branches, got {}",
1607            net.branches.len()
1608        );
1609        assert!(
1610            net.generators.len() > 500,
1611            "expected >500 generators, got {}",
1612            net.generators.len()
1613        );
1614
1615        // Verify base_mva
1616        assert!((net.base_mva - 100.0).abs() < 0.01);
1617
1618        // Verify at least one slack bus
1619        let slack_count = net
1620            .buses
1621            .iter()
1622            .filter(|b| b.bus_type == BusType::Slack)
1623            .count();
1624        assert!(slack_count >= 1, "no slack bus found");
1625
1626        // Total load (ERCOT 7k is larger than 2k)
1627        let total_load: f64 = net.total_load_mw();
1628        assert!(
1629            total_load > 10000.0,
1630            "total load too low: {total_load:.0} MW"
1631        );
1632
1633        eprintln!(
1634            "Texas7k: {} buses, {} branches, {} gens, {:.0} MW load",
1635            net.n_buses(),
1636            net.branches.len(),
1637            net.generators.len(),
1638            total_load
1639        );
1640    }
1641
1642    #[test]
1643    fn test_epc_vs_matpower_texas7k() {
1644        // Cross-format validation: compare EPC vs MATPOWER for Texas7k
1645        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
1646        let epc_path = std::path::PathBuf::from(&manifest)
1647            .join("../..")
1648            .join("tests/data/epc/Texas7k.epc");
1649        let mat_path = std::path::PathBuf::from(&manifest)
1650            .join("../..")
1651            .join("tests/data/epc/Texas7k.m");
1652
1653        if !epc_path.exists() || !mat_path.exists() {
1654            return; // skip if data not available
1655        }
1656
1657        let epc_net = parse_file(&epc_path).expect("EPC parse failed");
1658        let mat_net = crate::matpower::parse_file(&mat_path).expect("MATPOWER parse failed");
1659
1660        // Bus count must match
1661        assert_eq!(
1662            epc_net.n_buses(),
1663            mat_net.n_buses(),
1664            "bus count mismatch: EPC={} vs MATPOWER={}",
1665            epc_net.n_buses(),
1666            mat_net.n_buses()
1667        );
1668
1669        // Total load should be close
1670        let epc_load: f64 = epc_net.total_load_mw();
1671        let mat_load: f64 = mat_net.total_load_mw();
1672        let load_diff = (epc_load - mat_load).abs();
1673        let load_pct = load_diff / mat_load.abs().max(1.0) * 100.0;
1674        eprintln!(
1675            "Texas7k load: EPC={epc_load:.1} MW, MATPOWER={mat_load:.1} MW, diff={load_pct:.2}%"
1676        );
1677        assert!(load_pct < 1.0, "load mismatch too large: {load_pct:.2}%");
1678
1679        // Generators should be close
1680        let epc_gens = epc_net.generators.len();
1681        let mat_gens = mat_net.generators.len();
1682        eprintln!("Texas7k gens: EPC={epc_gens}, MATPOWER={mat_gens}");
1683    }
1684
1685    #[test]
1686    fn test_epc_vs_matpower_texas2k() {
1687        // Cross-format validation: compare EPC vs MATPOWER for Texas2k
1688        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
1689        let epc_path = std::path::PathBuf::from(&manifest)
1690            .join("../..")
1691            .join("tests/data/epc/Texas2k.epc");
1692        let mat_path = std::path::PathBuf::from(&manifest)
1693            .join("../..")
1694            .join("tests/data/epc/Texas2k.m");
1695
1696        if !epc_path.exists() || !mat_path.exists() {
1697            return; // skip if data not available
1698        }
1699
1700        let epc_net = parse_file(&epc_path).expect("EPC parse failed");
1701        let mat_net = crate::matpower::parse_file(&mat_path).expect("MATPOWER parse failed");
1702
1703        // Bus count must match
1704        assert_eq!(
1705            epc_net.n_buses(),
1706            mat_net.n_buses(),
1707            "bus count mismatch: EPC={} vs MATPOWER={}",
1708            epc_net.n_buses(),
1709            mat_net.n_buses()
1710        );
1711
1712        // Branch count should be close (may differ due to multi-section line encoding)
1713        let epc_branches = epc_net.branches.len();
1714        let mat_branches = mat_net.branches.len();
1715        let branch_diff = (epc_branches as i64 - mat_branches as i64).unsigned_abs();
1716        eprintln!("Branches: EPC={epc_branches}, MATPOWER={mat_branches}, diff={branch_diff}");
1717
1718        // Generator count should match
1719        let epc_gens = epc_net.generators.len();
1720        let mat_gens = mat_net.generators.len();
1721        eprintln!("Generators: EPC={epc_gens}, MATPOWER={mat_gens}");
1722
1723        // Total load should be close
1724        let epc_load: f64 = epc_net.total_load_mw();
1725        let mat_load: f64 = mat_net.total_load_mw();
1726        let load_diff = (epc_load - mat_load).abs();
1727        eprintln!(
1728            "Total load: EPC={epc_load:.1} MW, MATPOWER={mat_load:.1} MW, diff={load_diff:.1} MW"
1729        );
1730
1731        // Load should be within 1% (different file exporters may round differently)
1732        let load_pct = load_diff / mat_load.abs().max(1.0) * 100.0;
1733        assert!(load_pct < 1.0, "load mismatch too large: {load_pct:.2}%");
1734    }
1735}