Skip to main content

surge_io/psse/
reader.rs

1#![allow(clippy::needless_range_loop)]
2// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
3//! PSS/E RAW (.raw) file parser.
4//!
5//! Parses the PSS/E RAW power flow data format, versions 29–36.
6//! Version is read from the case header and gates version-specific parsing
7//! (v35+ column-header annotations, v36 extra sections).
8//!
9//! # File Structure
10//! - 3-line header (case ID, base MVA, version, two heading lines)
11//! - Data sections (Bus, Load, Fixed Shunt, Generator, Branch, Transformer, ...)
12//! - Each section terminated by `0` record (or `Q` for end-of-file)
13//!
14//! # Supported Sections
15//! - Bus Data
16//! - Load Data (accumulated into bus Pd/Qd)
17//! - Fixed Shunt Data (accumulated into bus Gs/Bs)
18//! - Generator Data
19//! - Non-Transformer Branch Data
20//! - Transformer Data (2-winding and 3-winding)
21//! - Area Interchange Data (stored as metadata in `network.area_schedules`)
22//! - Two-terminal HVDC link data (normalized into `network.hvdc.links`)
23//! - VSC HVDC link data (normalized into `network.hvdc.links`)
24//! - FACTS Device Data (stored in `network.facts_devices`; expanded before NR solve)
25//! - Switched Shunt Data (BINIT treated as fixed susceptance; no step control)
26//!
27//! - Impedance Correction Data (stored in `network.metadata.impedance_corrections`)
28//! - Multi-terminal DC line data (normalized into `network.hvdc.dc_grids`)
29//! - Multi-Section Line Data (stored in `network.metadata.multi_section_line_groups`)
30//! - Zone Data (stored in `network.metadata.regions`)
31//! - Inter-Area Transfer Data (stored in `network.metadata.scheduled_area_transfers`)
32//! - Owner Data (stored in `network.metadata.owners`)
33
34use std::collections::HashMap;
35use std::path::Path;
36
37use surge_network::Network;
38use surge_network::network::AreaSchedule;
39use surge_network::network::Owner;
40use surge_network::network::Region;
41use surge_network::network::facts::FactsType;
42use surge_network::network::impedance_correction::ImpedanceCorrectionTable;
43use surge_network::network::multi_section_line::MultiSectionLineGroup;
44use surge_network::network::scheduled_area_transfer::ScheduledAreaTransfer;
45use surge_network::network::topology::{
46    BusbarSection, ConnectivityNode, Substation as SubstationData, TerminalConnection,
47    TopologyMapping, VoltageLevel,
48};
49use surge_network::network::{Branch, BranchOpfControl, BranchType, Bus, BusType, Generator};
50use surge_network::network::{
51    DcBranch, DcBus, DcConverter, LccConverterTerminal, LccDcConverter, LccDcConverterRole,
52    LccHvdcControlMode, LccHvdcLink,
53};
54use surge_network::network::{FactsDevice, FactsMode};
55use surge_network::network::{NodeBreakerTopology, SwitchDevice, SwitchType};
56use surge_network::network::{OltcSpec, ParSpec};
57use surge_network::network::{
58    VscConverterAcControlMode, VscConverterTerminal, VscHvdcControlMode, VscHvdcLink,
59};
60use thiserror::Error;
61
62use super::multi_terminal_dc::{RawMtdcBus, RawMtdcConverter, RawMtdcLink, RawMtdcSystem};
63
64#[derive(Error, Debug)]
65pub enum PsseError {
66    #[error("I/O error: {0}")]
67    Io(#[from] std::io::Error),
68
69    #[error("parse error on line {line}: {message}")]
70    Parse { line: usize, message: String },
71
72    #[error("missing section: {0}")]
73    MissingSection(String),
74
75    #[error("unsupported PSS/E version: {0}")]
76    UnsupportedVersion(u32),
77
78    #[error("unexpected end of file in {0} section")]
79    UnexpectedEof(String),
80
81    /// PE-04: parse_f64 returned a non-finite (NaN or Inf) value.
82    #[error("non-finite float value on line {line}: {message}")]
83    NonFiniteValue { line: usize, message: String },
84}
85
86/// Parse a PSS/E RAW file from disk.
87pub fn parse_file(path: &Path) -> Result<Network, PsseError> {
88    let content = std::fs::read_to_string(path)?;
89    let name = path
90        .file_stem()
91        .and_then(|s| s.to_str())
92        .unwrap_or("unknown")
93        .to_string();
94    parse_string_with_name(&content, &name)
95}
96
97/// Parse a PSS/E RAW case from a string.
98pub fn parse_str(content: &str) -> Result<Network, PsseError> {
99    parse_string_with_name(content, "unknown")
100}
101
102fn parse_string_with_name(content: &str, name: &str) -> Result<Network, PsseError> {
103    let lines: Vec<&str> = content.lines().collect();
104    if lines.len() < 3 {
105        return Err(PsseError::Parse {
106            line: 1,
107            message: "PSS/E RAW file must have at least 3 header lines".into(),
108        });
109    }
110
111    // PSS/E v35+ files start with an @!IC,SBASE,REV,... column-header annotation on
112    // line 0; the actual case record (IC, SBASE, REV, ...) is on line 1.  Detect this
113    // and shift the header index by 1.
114    let header_line_idx = if lines[0].starts_with("@!") { 1 } else { 0 };
115
116    // Parse header record 1
117    let (sbase, version, freq_hz) = parse_header(lines[header_line_idx], header_line_idx + 1)?;
118
119    let mut network = Network::new(name);
120    network.base_mva = sbase;
121    network.freq_hz = freq_hz;
122
123    // Start parsing after the 3-line header (shifted by 1 for v35+ @! prefix line).
124    let mut pos = header_line_idx + 3;
125
126    // Skip preamble before bus data.  PSS/E v33+ files may include:
127    //   - A "SYSTEM-WIDE DATA" section terminated by "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA"
128    //   - Solver settings records with non-numeric first tokens (GENERAL, GAUSS, NEWTON, ADJUST …)
129    //   - Blank lines and @! column-header annotations
130    // All valid bus records begin with a numeric bus number — stop at the first such line.
131    while pos < lines.len() {
132        let line = lines[pos].trim();
133        if line.is_empty() || line.starts_with("@!") {
134            pos += 1;
135            continue;
136        }
137        if is_section_end(line) {
138            // "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA" — skip past it
139            pos += 1;
140            continue;
141        }
142        // Settings records (GENERAL, GAUSS, NEWTON, ADJUST …) have non-numeric first tokens
143        let first = line
144            .split(|c: char| c == ',' || c.is_ascii_whitespace())
145            .next()
146            .unwrap_or("");
147        if first.parse::<f64>().is_err() {
148            pos += 1;
149            continue;
150        }
151        break; // reached the first bus record
152    }
153
154    // Section 1: Bus Data
155    let buses;
156    (buses, pos) = parse_bus_section(&lines, pos)?;
157
158    // Build bus base_kv lookup for transformer tap conversion
159    let bus_basekv: HashMap<u32, f64> = buses.iter().map(|b| (b.number, b.base_kv)).collect();
160
161    network.buses = buses;
162
163    // Post-parse: fix vmin/vmax that are in kV rather than p.u.
164    sanitize_voltage_limits(&mut network);
165
166    // Section 2: Load Data — accumulate into bus Pd/Qd
167    let loads;
168    (loads, pos) = parse_load_section(&lines, pos)?;
169    apply_loads(&mut network, &loads).map_err(|err| PsseError::Parse {
170        line: 1,
171        message: err.to_string(),
172    })?;
173
174    // Section 3: Fixed Shunt Data — accumulate into bus Gs/Bs
175    let shunts;
176    (shunts, pos) = parse_fixed_shunt_section(&lines, pos)?;
177    // PSS/E v36 inserts VOLTAGE DROOP CONTROL DATA between fixed shunts and generators.
178    if version >= 36 {
179        let droop_controls;
180        (droop_controls, pos) = parse_voltage_droop_control_section(&lines, pos);
181        network.metadata.voltage_droop_controls = droop_controls;
182    }
183    pos = skip_to_section(&lines, pos, "generator");
184    apply_shunts(&mut network, &shunts);
185
186    // Section 4: Generator Data
187    let generators;
188    (generators, pos) = parse_generator_section(&lines, pos)?;
189    // PSS/E v36 inserts SWITCHING DEVICE RATING SET DATA between generators and branches.
190    if version >= 36 {
191        let rating_sets;
192        (rating_sets, pos) = parse_switching_device_rating_set_section(&lines, pos);
193        network.metadata.switching_device_rating_sets = rating_sets;
194    }
195    pos = skip_to_section(&lines, pos, "branch");
196    network.generators = generators;
197
198    // Section 5: Non-Transformer Branch Data
199    let branches;
200    let branch_terminal_shunts;
201    (branches, branch_terminal_shunts, pos) = parse_branch_section(&lines, pos, sbase)?;
202    network.branches = branches;
203    // Apply GI/BI/GJ/BJ terminal shunts as fixed bus shunts (same as MATPOWER).
204    apply_shunts(&mut network, &branch_terminal_shunts);
205
206    // Section 5a: System Switching Device Data (v34+, between branches and transformers).
207    // Only present in v34+ files; older files go directly to transformers.
208    let sys_switch_devices;
209    if version >= 34 {
210        if let Some(sys_pos) =
211            seek_section(&lines, pos.saturating_sub(1), "system switching device")
212        {
213            (sys_switch_devices, pos) = parse_system_switching_device_section(&lines, sys_pos);
214            pos = skip_to_section(&lines, pos, "transformer");
215        } else {
216            sys_switch_devices = Vec::new();
217        }
218    } else {
219        sys_switch_devices = Vec::new();
220    }
221
222    // Section 6: Transformer Data
223    let (transformers, star_buses, oltc_specs, par_specs, pos) =
224        parse_transformer_section(&lines, pos, sbase, version, &bus_basekv)?;
225    // Add fictitious star buses (from 3-winding transformer expansion) before branches
226    // so the Y-bus builder can find them by bus number.
227    network.buses.extend(star_buses);
228    network.branches.extend(transformers);
229    network.controls.oltc_specs.extend(oltc_specs);
230    network.controls.par_specs.extend(par_specs);
231
232    // Sections 7–15: Area Interchange, DC Lines, VSC DC, FACTS, Switched Shunts.
233    //
234    // Back up one line so that seek_section can re-examine the transformer end
235    // marker, which in standard PSS/E files doubles as the area-interchange begin
236    // marker (e.g. "0 / END OF TRANSFORMER DATA, BEGIN AREA INTERCHANGE DATA").
237    // Each seek_section call scans from `seek_start` through ALL remaining lines.
238    // We always scan from the same starting point so that each section's
239    // begin-marker is independently located regardless of where the previous
240    // section's parser left off.
241    let seek_start = if pos > 0 { pos - 1 } else { pos };
242
243    // Section 7: Area Interchange Data
244    if let Some(ai_pos) = seek_section(&lines, seek_start, "area interchange") {
245        let (areas, _) = parse_area_schedule_section(&lines, ai_pos)?;
246        network.area_schedules = areas;
247    }
248
249    // Section 8: Two-Terminal DC Line Data
250    if let Some(dc_pos) = seek_section(&lines, seek_start, "two-terminal dc") {
251        let (lcc_links, _) = parse_dc_line_section(&lines, dc_pos)?;
252        network.hvdc.links = lcc_links
253            .into_iter()
254            .map(surge_network::network::HvdcLink::Lcc)
255            .collect();
256    }
257
258    // Section 9: VSC DC Line Data
259    if let Some(vsc_pos) = seek_section(&lines, seek_start, "vsc dc line") {
260        let (vsc_lines, _) = parse_vsc_dc_section(&lines, vsc_pos)?;
261        network.hvdc.links.extend(
262            vsc_lines
263                .into_iter()
264                .map(surge_network::network::HvdcLink::Vsc),
265        );
266    }
267
268    // Section 10: Impedance Correction Data
269    if let Some(ic_pos) = seek_section(&lines, seek_start, "impedance correction") {
270        let (tables, _) = parse_impedance_correction_section(&lines, ic_pos);
271        network.metadata.impedance_corrections = tables;
272    }
273
274    // Section 11: Multi-Terminal DC Data
275    if let Some(mt_pos) = seek_section(&lines, seek_start, "multi-terminal dc") {
276        let (mt_lines, _) = parse_multi_terminal_dc_section(&lines, mt_pos);
277        normalize_dc_grids(&mut network, &mt_lines);
278    }
279
280    // Section 12: Multi-Section Line Data
281    if let Some(ms_pos) = seek_section(&lines, seek_start, "multi-section line") {
282        let (ms_lines, _) = parse_multi_section_line_section(&lines, ms_pos);
283        network.metadata.multi_section_line_groups = ms_lines;
284    }
285
286    // Section 13: Zone Data
287    if let Some(z_pos) = seek_section(&lines, seek_start, "zone") {
288        let (zones, _) = parse_zone_section(&lines, z_pos);
289        network.metadata.regions = zones;
290    }
291
292    // Section 13b: Inter-Area Transfer Data
293    if let Some(ia_pos) = seek_section(&lines, seek_start, "inter-area transfer") {
294        let (transfers, _) = parse_inter_area_transfer_section(&lines, ia_pos);
295        network.metadata.scheduled_area_transfers = transfers;
296    }
297
298    // Section 13c: Owner Data
299    if let Some(ow_pos) = seek_section(&lines, seek_start, "owner") {
300        let (owners, _) = parse_owner_section(&lines, ow_pos);
301        network.metadata.owners = owners;
302    }
303
304    // Section 14: FACTS Control Device Data
305    // Seek "facts" rather than "facts device" — real PSS/E files use
306    // "FACTS CONTROL DEVICE DATA" where "facts device" is not contiguous.
307    if let Some(facts_pos) = seek_section(&lines, seek_start, "facts") {
308        let (facts, _) = parse_facts_section(&lines, facts_pos);
309        network.facts_devices = facts;
310    }
311
312    // Section 15: Switched Shunt Data
313    // seek_section scans ALL lines (including any intermediate data records) until it
314    // finds the "BEGIN SWITCHED SHUNT DATA" marker.
315    if let Some(sw_pos) = seek_section(&lines, seek_start, "switched shunt") {
316        let (sw_shunts, _) = parse_switched_shunt_section(&lines, sw_pos)?;
317        let base_mva = network.base_mva;
318        apply_switched_shunts(&mut network, &sw_shunts, base_mva);
319    }
320
321    // Section 17+: Induction Machine Data (v35+).
322    if let Some(im_pos) = seek_section(&lines, seek_start, "induction machine") {
323        let machines = parse_induction_machine_section(&lines, im_pos);
324        network.induction_machines = machines;
325    }
326
327    // Section 16+: Substation Data (v35+).
328    // Contains the full node-breaker topology: substations, nodes, switching devices,
329    // and terminal connections.  When present, builds a NodeBreakerTopology on the network.
330    if let Some(sub_pos) = seek_section(&lines, seek_start, "substation") {
331        let sm = parse_substation_data_section(&lines, sub_pos, &bus_basekv, &sys_switch_devices);
332        if !sm.connectivity_nodes.is_empty() {
333            network.topology = Some(sm);
334        }
335    } else if !sys_switch_devices.is_empty() {
336        // No SUBSTATION DATA section, but we have system-level switching devices.
337        // Build a minimal NodeBreakerTopology from the system switching devices.
338        network.topology = Some(build_sys_switch_model(
339            &sys_switch_devices,
340            &network,
341            &bus_basekv,
342        ));
343    }
344    Ok(network)
345}
346
347// ---------------------------------------------------------------------------
348// Header parsing
349// ---------------------------------------------------------------------------
350
351fn parse_header(line: &str, line_num: usize) -> Result<(f64, u32, f64), PsseError> {
352    let fields = tokenize_record(line);
353    if fields.is_empty() {
354        return Err(PsseError::Parse {
355            line: line_num,
356            message: "empty header line".into(),
357        });
358    }
359
360    // IC, SBASE, REV, XFRRAT, NXFRAT, BASFRQ / ...
361    let sbase = if fields.len() > 1 {
362        parse_f64(&fields[1], line_num, "SBASE")?
363    } else {
364        100.0
365    };
366
367    let version = if fields.len() > 2 {
368        parse_f64(&fields[2], line_num, "REV")? as u32
369    } else {
370        33 // default to v33
371    };
372
373    // BASFRQ is field index 5 (IC=0, SBASE=1, REV=2, XFRRAT=3, NXFRAT=4, BASFRQ=5).
374    // Default to 60 Hz if not present or zero.
375    let freq_hz = if fields.len() > 5 {
376        let f = parse_f64(&fields[5], line_num, "BASFRQ").unwrap_or(60.0);
377        if f > 0.0 { f } else { 60.0 }
378    } else {
379        60.0
380    };
381
382    Ok((sbase, version, freq_hz))
383}
384
385// ---------------------------------------------------------------------------
386// Section-navigation helper
387// ---------------------------------------------------------------------------
388
389/// Skip over unknown "interpolated" PSS/E sections until the section whose
390/// `0 / BEGIN … DATA` marker contains `target` (case-insensitive).
391///
392/// PSS/E v36 inserts extra sections (VOLTAGE DROOP CONTROL DATA,
393/// SWITCHING DEVICE RATING SET DATA, SYSTEM SWITCHING DEVICE DATA, …)
394/// between the standard sections that older parsers know about.
395///
396/// After a standard section's parser returns, `pos` points to the first line
397/// of the next (possibly unknown) section.  This function advances `pos` past
398/// any such intermediate sections by scanning forward for section-end lines
399/// that contain the target keyword.
400///
401/// For standard v33/v34 files with no extra sections the function detects that
402/// `pos` is already at real data and returns it unchanged.
403fn skip_to_section(lines: &[&str], pos: usize, target: &str) -> usize {
404    let target_lc = target.to_ascii_lowercase();
405    let mut i = pos;
406    while i < lines.len() {
407        let line = lines[i].trim();
408        if line.is_empty() || line.starts_with("@!") {
409            i += 1;
410            continue;
411        }
412        if is_section_end(line) {
413            let lc = line.to_ascii_lowercase();
414            // Match only when the target keyword appears AFTER "begin" in the
415            // line.  Using a simple substring search starting from the "begin"
416            // position prevents false matches where the target keyword appears
417            // in the "END OF X DATA" prefix but not in the "BEGIN Y DATA"
418            // suffix (e.g. "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA"
419            // must not match when seeking "generator").
420            if let Some(begin_pos) = lc.find("begin")
421                && lc[begin_pos..].contains(&target_lc)
422            {
423                return i + 1;
424            }
425            // End of an unknown intermediate section (or an empty target
426            // section) — keep scanning.
427            i += 1;
428            continue;
429        }
430        // Non-blank, non-@!, non-section-end: we are already inside real data.
431        // The caller's section parser can handle the @! annotation skip itself.
432        break;
433    }
434    pos // fallback: already positioned at target section
435}
436
437/// Scan ALL lines from `start` (including data records in intermediate sections)
438/// until a section-end line containing "BEGIN <target>" is found.
439///
440/// Unlike `skip_to_section`, this does not break on data records, so it correctly
441/// locates sections that appear after non-empty intermediate sections (e.g.
442/// Switched Shunt Data follows Area Interchange + Two-Terminal DC + FACTS data).
443///
444/// The target keyword is only matched when it appears AFTER "begin" in the line,
445/// preventing false matches with the "END OF X DATA" prefix of combo markers like
446/// "0 / END OF AREA INTERCHANGE DATA, BEGIN TWO-TERMINAL DC DATA".
447///
448/// Returns `Some(first_data_line_pos)` or `None` if the section is not present.
449fn seek_section(lines: &[&str], start: usize, target: &str) -> Option<usize> {
450    let target_lc = target.to_ascii_lowercase();
451    for i in start..lines.len() {
452        let line = lines[i].trim();
453        if is_section_end(line) {
454            let lc = line.to_ascii_lowercase();
455            if let Some(begin_pos) = lc.find("begin")
456                && lc[begin_pos..].contains(&target_lc)
457            {
458                return Some(i + 1);
459            }
460        }
461    }
462    None
463}
464
465// ---------------------------------------------------------------------------
466// Bus Data
467// ---------------------------------------------------------------------------
468
469use crate::parse_utils::{
470    RawLoad, RawShunt, RawSwitchedShunt, apply_loads, apply_shunts, apply_switched_shunts,
471    sanitize_voltage_limits, unquote,
472};
473
474fn parse_bus_section(lines: &[&str], start: usize) -> Result<(Vec<Bus>, usize), PsseError> {
475    let mut buses = Vec::new();
476    let mut pos = start;
477
478    while pos < lines.len() {
479        let line = lines[pos].trim();
480        if is_section_end(line) {
481            return Ok((buses, pos + 1));
482        }
483        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
484            return Ok((buses, pos + 1));
485        }
486        if line.starts_with("@!") {
487            pos += 1;
488            continue;
489        }
490
491        let fields = tokenize_record(line);
492        if fields.is_empty() {
493            pos += 1;
494            continue;
495        }
496
497        let line_num = pos + 1;
498        // I, 'NAME', BASKV, IDE, AREA, ZONE, OWNER, VM, VA
499        // Minimum fields: I, NAME, BASKV, IDE (4 fields)
500        if fields.len() < 4 {
501            pos += 1;
502            continue;
503        }
504
505        let number = parse_f64(&fields[0], line_num, "bus number")? as u32;
506        let name = unquote(&fields[1]);
507        let base_kv = parse_f64(&fields[2], line_num, "BASKV")?;
508        let ide = parse_f64(&fields[3], line_num, "IDE")? as u32;
509
510        let area = if fields.len() > 4 {
511            parse_f64(&fields[4], line_num, "AREA")? as u32
512        } else {
513            1
514        };
515        let zone = if fields.len() > 5 {
516            parse_f64(&fields[5], line_num, "ZONE")? as u32
517        } else {
518            1
519        };
520        let owner = if fields.len() > 6 {
521            parse_f64(&fields[6], line_num, "OWNER").unwrap_or(1.0) as u32
522        } else {
523            1
524        };
525        let vm = if fields.len() > 7 {
526            parse_f64(&fields[7], line_num, "VM")?
527        } else {
528            1.0
529        };
530        let va_deg = if fields.len() > 8 {
531            parse_f64(&fields[8], line_num, "VA")?
532        } else {
533            0.0
534        };
535
536        let bus_type = match ide {
537            2 => BusType::PV,
538            3 => BusType::Slack,
539            4 => BusType::Isolated,
540            _ => BusType::PQ,
541        };
542
543        // fields 9-10: NVHI, NVLO (normal voltage limits); 11-12: EVHI, EVLO
544        let vmax = if fields.len() > 9 {
545            parse_f64(&fields[9], line_num, "NVHI").unwrap_or(1.1)
546        } else {
547            1.1
548        };
549        let vmin = if fields.len() > 10 {
550            parse_f64(&fields[10], line_num, "NVLO").unwrap_or(0.9)
551        } else {
552            0.9
553        };
554
555        // PSS/E v35+ adds LATITUDE and LONGITUDE after NVHI/NVLO/EVHI/EVLO (fields 9–12).
556        // Parse them if present; treat 0.0 as absent (no plant is at (0°,0°)).
557        let latitude = if fields.len() > 13 {
558            parse_f64(&fields[13], line_num, "LATITUDE")
559                .ok()
560                .filter(|&v| v.abs() > 1e-10)
561        } else {
562            None
563        };
564        let longitude = if fields.len() > 14 {
565            parse_f64(&fields[14], line_num, "LONGITUDE")
566                .ok()
567                .filter(|&v| v.abs() > 1e-10)
568        } else {
569            None
570        };
571
572        buses.push(Bus {
573            number,
574            name,
575            bus_type,
576            shunt_conductance_mw: 0.0, // will be set from Fixed Shunt section
577            shunt_susceptance_mvar: 0.0,
578            area,
579            voltage_magnitude_pu: vm,
580            voltage_angle_rad: va_deg.to_radians(),
581            base_kv,
582            zone,
583            voltage_max_pu: vmax,
584            voltage_min_pu: vmin,
585            island_id: 0,
586            latitude,
587            longitude,
588            owners: if owner > 0 {
589                vec![surge_network::network::OwnershipEntry {
590                    owner,
591                    fraction: 1.0,
592                }]
593            } else {
594                Vec::new()
595            },
596            ..Bus::new(0, BusType::PQ, 0.0)
597        });
598
599        pos += 1;
600    }
601
602    Err(PsseError::UnexpectedEof("Bus Data".into()))
603}
604
605// ---------------------------------------------------------------------------
606// Load Data
607// ---------------------------------------------------------------------------
608
609fn parse_load_section(lines: &[&str], start: usize) -> Result<(Vec<RawLoad>, usize), PsseError> {
610    let mut loads = Vec::new();
611    let mut pos = start;
612
613    while pos < lines.len() {
614        let line = lines[pos].trim();
615        if is_section_end(line) {
616            return Ok((loads, pos + 1));
617        }
618        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
619            return Ok((loads, pos + 1));
620        }
621        if line.starts_with("@!") {
622            pos += 1;
623            continue;
624        }
625
626        let fields = tokenize_record(line);
627        if fields.is_empty() {
628            pos += 1;
629            continue;
630        }
631
632        let line_num = pos + 1;
633        // I, ID, STATUS, AREA, ZONE, PL, QL, IP, IQ, YP, YQ, OWNER, SCALE, INTRPT, DGENP, DGENQ
634        if fields.len() < 6 {
635            pos += 1;
636            continue;
637        }
638
639        let bus = parse_f64(&fields[0], line_num, "bus")? as u32;
640        let id = unquote(&fields[1]);
641        // Older v29/v30 files may use text status codes ('A', 'I', 'BL', etc.).
642        let status = parse_status(&fields[2], line_num, "STATUS")?;
643        // fields[3] = AREA, fields[4] = ZONE (skip)
644        let pl = parse_f64(&fields[5], line_num, "PL")?;
645        let ql = if fields.len() > 6 {
646            parse_f64(&fields[6], line_num, "QL")?
647        } else {
648            0.0
649        };
650        // ZIP components: IP (const-current P), IQ (const-current Q),
651        // YP (const-admittance P), YQ (const-admittance Q) — all at V=1.0 pu.
652        // Collapse to constant-power equivalent (MATPOWER convention: total P = PL+IP+YP).
653        let ip = if fields.len() > 7 {
654            parse_f64(&fields[7], line_num, "IP").unwrap_or(0.0)
655        } else {
656            0.0
657        };
658        let iq = if fields.len() > 8 {
659            parse_f64(&fields[8], line_num, "IQ").unwrap_or(0.0)
660        } else {
661            0.0
662        };
663        let yp = if fields.len() > 9 {
664            parse_f64(&fields[9], line_num, "YP").unwrap_or(0.0)
665        } else {
666            0.0
667        };
668        let yq = if fields.len() > 10 {
669            parse_f64(&fields[10], line_num, "YQ").unwrap_or(0.0)
670        } else {
671            0.0
672        };
673        let p_total = pl + ip + yp;
674        let q_total = ql + iq + yq;
675
676        // Compute ZIP fractions. Default to constant-power when total is ~zero.
677        let (zip_pz, zip_pi, zip_pp) = if p_total.abs() > 1e-10 {
678            (yp / p_total, ip / p_total, pl / p_total)
679        } else {
680            (0.0, 0.0, 1.0)
681        };
682        let (zip_qz, zip_qi, zip_qp) = if q_total.abs() > 1e-10 {
683            (yq / q_total, iq / q_total, ql / q_total)
684        } else {
685            (0.0, 0.0, 1.0)
686        };
687
688        if ip.abs() > 1e-10 || iq.abs() > 1e-10 || yp.abs() > 1e-10 || yq.abs() > 1e-10 {
689            tracing::debug!(
690                bus,
691                ip,
692                iq,
693                yp,
694                yq,
695                "PSS/E load at bus {bus}: ZIP fractions preserved \
696                 (Z={zip_pz:.4}/{zip_qz:.4}, I={zip_pi:.4}/{zip_qi:.4}, P={zip_pp:.4}/{zip_qp:.4})"
697            );
698        }
699
700        let owner = if fields.len() > 11 {
701            Some(parse_f64(&fields[11], line_num, "OWNER").unwrap_or(1.0) as u32)
702        } else {
703            None
704        };
705        // SCALE field (index 12): 1 = conforming (default), 0 = non-conforming.
706        let conforming = if fields.len() > 12 {
707            let scale_val = parse_f64(&fields[12], line_num, "SCALE").unwrap_or(1.0);
708            scale_val.abs() > 0.5
709        } else {
710            true
711        };
712
713        loads.push(RawLoad {
714            bus,
715            id,
716            status,
717            owner,
718            pl: p_total,
719            ql: q_total,
720            conforming,
721            zip_p_impedance_frac: zip_pz,
722            zip_p_current_frac: zip_pi,
723            zip_p_power_frac: zip_pp,
724            zip_q_impedance_frac: zip_qz,
725            zip_q_current_frac: zip_qi,
726            zip_q_power_frac: zip_qp,
727        });
728
729        pos += 1;
730    }
731
732    Err(PsseError::UnexpectedEof("Load Data".into()))
733}
734
735// ---------------------------------------------------------------------------
736// Fixed Shunt Data
737// ---------------------------------------------------------------------------
738
739fn parse_fixed_shunt_section(
740    lines: &[&str],
741    start: usize,
742) -> Result<(Vec<RawShunt>, usize), PsseError> {
743    let mut shunts = Vec::new();
744    let mut pos = start;
745
746    while pos < lines.len() {
747        let line = lines[pos].trim();
748        if is_section_end(line) {
749            return Ok((shunts, pos + 1));
750        }
751        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
752            return Ok((shunts, pos + 1));
753        }
754        if line.starts_with("@!") {
755            pos += 1;
756            continue;
757        }
758
759        let fields = tokenize_record(line);
760        if fields.is_empty() {
761            pos += 1;
762            continue;
763        }
764
765        let line_num = pos + 1;
766        // I, ID, STATUS, GL, BL
767        if fields.len() < 5 {
768            pos += 1;
769            continue;
770        }
771
772        let bus = parse_f64(&fields[0], line_num, "bus")? as u32;
773        // fields[1] = ID
774        // Older v29/v30 files may use text status codes ('A', 'I', 'BL', etc.).
775        let status = parse_status(&fields[2], line_num, "STATUS")?;
776        let gl = parse_f64(&fields[3], line_num, "GL")?;
777        let bl = parse_f64(&fields[4], line_num, "BL")?;
778
779        shunts.push(RawShunt {
780            bus,
781            status,
782            gl,
783            bl,
784        });
785
786        pos += 1;
787    }
788
789    Err(PsseError::UnexpectedEof("Fixed Shunt Data".into()))
790}
791
792// ---------------------------------------------------------------------------
793// Switched Shunt Data
794// ---------------------------------------------------------------------------
795
796/// Parse PSS/E Switched Shunt Data section.
797///
798/// Record format (v33/v34/v35/v36):
799///   I, MODSW, ADJM, STAT, VSWHI, VSWLO, SWREM, RMPCT, RMIDNT, BINIT [, N1, B1, ...]
800///
801/// For power flow purposes all in-service entries are treated as fixed shunts
802/// at their current operating point `BINIT` (Mvar, capacitive positive).
803/// Controlled shunts (MODSW ≠ 0) that are not yet at steady state will
804/// produce the same small voltage errors as any initialisation mismatch —
805/// acceptable for a warm-start power flow.  Full switched-shunt control
806/// (discrete or continuous stepping) is a future enhancement.
807///
808/// Parse the PSS/E SWITCHED SHUNT DATA section into `RawSwitchedShunt` records.
809///
810/// Returns `(shunts, next_pos)`. Never returns `Err` — individual malformed
811/// records are silently skipped so that files with unexpected formats still
812/// parse the records they can.
813///
814/// PSS/E record layout (v30/v33/v34/v35):
815/// ```text
816/// I, MODSW, ADJM, STAT, VSWHI, VSWLO, SWREM, RMPCT, RMIDNT, BINIT,
817/// N1, B1, N2, B2, N3, B3, N4, B4, N5, B5, N6, B6, N7, B7, N8, B8
818/// ```
819/// Fields 10 onward (N1,B1,...,N8,B8) are optional — older files may omit them.
820fn parse_switched_shunt_section(
821    lines: &[&str],
822    start: usize,
823) -> Result<(Vec<RawSwitchedShunt>, usize), PsseError> {
824    let mut shunts = Vec::new();
825    let mut pos = start;
826
827    while pos < lines.len() {
828        let line = lines[pos].trim();
829
830        if is_section_end(line) {
831            return Ok((shunts, pos + 1));
832        }
833        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
834            return Ok((shunts, pos + 1));
835        }
836        if line.is_empty() || line.starts_with("@!") {
837            pos += 1;
838            continue;
839        }
840
841        let fields = tokenize_record(line);
842        if fields.is_empty() {
843            pos += 1;
844            continue;
845        }
846
847        // Minimum: I, MODSW, ADJM, STAT, VSWHI, VSWLO, SWREM, RMPCT, RMIDNT, BINIT (10 fields)
848        if fields.len() < 10 {
849            return Err(PsseError::Parse {
850                line: pos + 1,
851                message: format!(
852                    "switched shunt record is truncated: expected at least 10 fields, got {}",
853                    fields.len()
854                ),
855            });
856        }
857
858        let bus = match fields[0].trim_matches('\'').parse::<f64>() {
859            Ok(v) => v as u32,
860            Err(_) => {
861                return Err(PsseError::Parse {
862                    line: pos + 1,
863                    message: "invalid switched shunt bus number".into(),
864                });
865            }
866        };
867
868        let modsw = fields[1].trim_matches('\'').parse::<f64>().unwrap_or(0.0) as i32;
869        // fields[2] = ADJM (adjustment mechanism — not used for PF)
870        let stat = match fields[3].trim_matches('\'').parse::<f64>() {
871            Ok(v) => v as i32,
872            Err(_) => {
873                return Err(PsseError::Parse {
874                    line: pos + 1,
875                    message: "invalid switched shunt status".into(),
876                });
877            }
878        };
879        let vswhi = fields[4].trim_matches('\'').parse::<f64>().unwrap_or(1.1);
880        let vswlo = fields[5].trim_matches('\'').parse::<f64>().unwrap_or(0.9);
881        let swrem = fields[6].trim_matches('\'').parse::<f64>().unwrap_or(0.0) as u32;
882        // fields[7] = RMPCT, fields[8] = RMIDNT (quoted identifier string — skip)
883        let binit_mvar = match fields[9].trim_matches('\'').parse::<f64>() {
884            Ok(v) => v,
885            Err(_) => {
886                return Err(PsseError::Parse {
887                    line: pos + 1,
888                    message: "invalid switched shunt BINIT".into(),
889                });
890            }
891        };
892
893        // Parse optional (N, B) step blocks: up to 8 pairs starting at field 10.
894        // Each pair is (Ni: i32, Bi: f64) — Bi is in Mvar per step at 1 pu voltage.
895        let mut blocks = Vec::new();
896        let mut fi = 10usize;
897        while fi + 1 < fields.len() && blocks.len() < 8 {
898            let ni = fields[fi].trim_matches('\'').parse::<f64>().unwrap_or(0.0) as i32;
899            let bi = fields[fi + 1]
900                .trim_matches('\'')
901                .parse::<f64>()
902                .unwrap_or(0.0);
903            blocks.push((ni, bi));
904            fi += 2;
905        }
906
907        shunts.push(RawSwitchedShunt {
908            bus,
909            modsw,
910            stat,
911            vswhi,
912            vswlo,
913            swrem,
914            binit: binit_mvar,
915            blocks,
916        });
917
918        pos += 1;
919    }
920
921    Ok((shunts, pos))
922}
923
924// ---------------------------------------------------------------------------
925// Generator Data
926// ---------------------------------------------------------------------------
927
928fn parse_generator_section(
929    lines: &[&str],
930    start: usize,
931) -> Result<(Vec<Generator>, usize), PsseError> {
932    let mut generators = Vec::new();
933    let mut pos = start;
934
935    while pos < lines.len() {
936        let line = lines[pos].trim();
937        if is_section_end(line) {
938            return Ok((generators, pos + 1));
939        }
940        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
941            return Ok((generators, pos + 1));
942        }
943        if line.starts_with("@!") {
944            pos += 1;
945            continue;
946        }
947
948        let fields = tokenize_record(line);
949        if fields.is_empty() {
950            pos += 1;
951            continue;
952        }
953
954        let line_num = pos + 1;
955        // I, ID, PG, QG, QT, QB, VS, IREG, MBASE, ZR, ZX, RT, XT, GTAP, STAT, RMPCT, PT, PB, ...
956        if fields.len() < 15 {
957            pos += 1;
958            continue;
959        }
960
961        let bus = parse_f64(&fields[0], line_num, "bus")? as u32;
962        // fields[1] = ID (machine ID, strip surrounding quotes and whitespace)
963        let machine_id: Option<String> = fields.get(1).map(|s| {
964            let trimmed = s
965                .trim()
966                .trim_matches('\'')
967                .trim_matches('"')
968                .trim()
969                .to_string();
970            if trimmed.is_empty() {
971                "1".to_string()
972            } else {
973                trimmed
974            }
975        });
976        let pg = parse_f64(&fields[2], line_num, "PG")?;
977        let qg = parse_f64(&fields[3], line_num, "QG")?;
978        let qt = parse_f64(&fields[4], line_num, "QT")?;
979        let qb = parse_f64(&fields[5], line_num, "QB")?;
980        let vs = parse_f64(&fields[6], line_num, "VS")?;
981        // fields[7] = IREG: remote regulated bus (0 = own terminal bus)
982        let reg_bus: Option<u32> = if fields.len() > 7 && !fields[7].trim().is_empty() {
983            let ireg = parse_f64(&fields[7], line_num, "IREG").unwrap_or(0.0) as i64;
984            if ireg != 0 {
985                Some(ireg.unsigned_abs() as u32)
986            } else {
987                None
988            }
989        } else {
990            None
991        };
992        let mbase = parse_f64(&fields[8], line_num, "MBASE")?;
993        // fields[9] = ZR (armature resistance), fields[10] = ZX (machine leakage reactance / xs)
994        let xs = if fields.len() > 10 && !fields[10].trim().is_empty() {
995            let zx = parse_f64(&fields[10], line_num, "ZX").unwrap_or(0.0);
996            if zx > 0.0 { Some(zx) } else { None }
997        } else {
998            None
999        };
1000        // fields[11..13] = RT, XT, GTAP
1001        // STAT: required in the source record; empty or unknown values are errors.
1002        // Older v29/v30 files may use text status codes ('A', 'I', 'BL', etc.).
1003        let stat = if fields.len() > 14 {
1004            parse_status(&fields[14], line_num, "STAT")?
1005        } else {
1006            return Err(PsseError::Parse {
1007                line: line_num,
1008                message: "missing required generator status (STAT) field".into(),
1009            });
1010        };
1011
1012        let mut pt = if fields.len() > 16 && !fields[16].trim().is_empty() {
1013            parse_f64(&fields[16], line_num, "PT")?
1014        } else {
1015            9999.0 // PSS/E convention for "unlimited"
1016        };
1017        let mut pb = if fields.len() > 17 {
1018            parse_f64(&fields[17], line_num, "PB")?
1019        } else {
1020            0.0
1021        };
1022
1023        // Sanity-check pmax/pmin.  PSS/E PTIv33 files often store 0.0 for PT/PB
1024        // when the data was not explicitly set.  Infer sensible bounds from pg.
1025        if pt < 0.0 {
1026            // Negative pmax is physically impossible — take absolute value.
1027            pt = pt.abs();
1028        }
1029        if pt < pg && pg > 0.0 {
1030            // pmax below the scheduled output: infer pmax = pg * 1.1 (10% headroom).
1031            pt = pg * 1.1;
1032        }
1033        if pt == 0.0 && pg > 0.0 {
1034            // pmax == 0 with non-zero scheduled output: the PT field was not set in the
1035            // PSS/E file.  Use 9999 MW (the PSS/E convention for "unlimited") rather than
1036            // inventing a value derived from the current dispatch.
1037            tracing::warn!(
1038                "PSS/E generator at bus {bus}: PT=0 with PG={pg:.1} MW; \
1039                 setting Pmax=9999 MW (field not provided in source file)"
1040            );
1041            pt = 9999.0;
1042        }
1043        // pmin must not exceed pmax.
1044        if pb > pt {
1045            pb = 0.0;
1046        }
1047
1048        generators.push(Generator {
1049            bus,
1050            machine_id,
1051            p: pg,
1052            q: qg,
1053            qmax: qt,
1054            qmin: qb,
1055            voltage_setpoint_pu: vs,
1056            reg_bus,
1057            machine_base_mva: mbase,
1058            pmax: pt,
1059            pmin: pb,
1060            in_service: stat > 0,
1061            cost: None,
1062            forced_outage_rate: None,
1063            agc_participation_factor: None,
1064            h_inertia_s: None,
1065            pfr_eligible: true,
1066            fault_data: xs.map(|xs_val| surge_network::network::GenFaultData {
1067                xs: Some(xs_val),
1068                ..Default::default()
1069            }),
1070            owners: parse_multi_owner_fields(&fields, 18),
1071            ..Generator::new(0, 0.0, 1.0)
1072        });
1073
1074        pos += 1;
1075    }
1076
1077    Err(PsseError::UnexpectedEof("Generator Data".into()))
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Non-Transformer Branch Data
1082// ---------------------------------------------------------------------------
1083
1084fn parse_branch_section(
1085    lines: &[&str],
1086    start: usize,
1087    sbase: f64,
1088) -> Result<(Vec<Branch>, Vec<RawShunt>, usize), PsseError> {
1089    let mut branches = Vec::new();
1090    let mut terminal_shunts: Vec<RawShunt> = Vec::new();
1091    let mut pos = start;
1092
1093    while pos < lines.len() {
1094        let line = lines[pos].trim();
1095        if is_section_end(line) {
1096            return Ok((branches, terminal_shunts, pos + 1));
1097        }
1098        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
1099            return Ok((branches, terminal_shunts, pos + 1));
1100        }
1101        if line.starts_with("@!") {
1102            pos += 1;
1103            continue;
1104        }
1105
1106        let fields = tokenize_record(line);
1107        if fields.is_empty() {
1108            pos += 1;
1109            continue;
1110        }
1111
1112        let line_num = pos + 1;
1113        // I, J, CKT, R, X, B, RATEA, RATEB, RATEC, GI, BI, GJ, BJ, ST, MET, LEN, ...
1114        if fields.len() < 14 {
1115            pos += 1;
1116            continue;
1117        }
1118
1119        let from_bus = parse_f64(&fields[0], line_num, "I")? as u32;
1120        let to_bus = (parse_f64(&fields[1], line_num, "J")? as i64).unsigned_abs() as u32;
1121        let circuit = unquote(&fields[2]);
1122        let r = parse_f64(&fields[3], line_num, "R")?;
1123        let x = parse_f64(&fields[4], line_num, "X")?;
1124        let b = parse_f64(&fields[5], line_num, "B")?;
1125        let rate_a = if fields.len() > 6 {
1126            parse_f64(&fields[6], line_num, "RATEA")?
1127        } else {
1128            0.0
1129        };
1130        let rate_b = if fields.len() > 7 {
1131            parse_f64(&fields[7], line_num, "RATEB")?
1132        } else {
1133            0.0
1134        };
1135        let rate_c = if fields.len() > 8 {
1136            parse_f64(&fields[8], line_num, "RATEC")?
1137        } else {
1138            0.0
1139        };
1140        // GI, BI, GJ, BJ: terminal shunt admittances in pu — accumulate to bus shunts.
1141        // Branch struct has no per-end shunt fields; warn when non-zero so users know.
1142        let gi = if fields.len() > 9 {
1143            parse_f64(&fields[9], line_num, "GI")?
1144        } else {
1145            0.0
1146        };
1147        let bi = if fields.len() > 10 {
1148            parse_f64(&fields[10], line_num, "BI")?
1149        } else {
1150            0.0
1151        };
1152        let gj = if fields.len() > 11 {
1153            parse_f64(&fields[11], line_num, "GJ")?
1154        } else {
1155            0.0
1156        };
1157        let bj = if fields.len() > 12 {
1158            parse_f64(&fields[12], line_num, "BJ")?
1159        } else {
1160            0.0
1161        };
1162        // GI/BI apply at the from-bus terminal; GJ/BJ at the to-bus terminal.
1163        // Accumulate as fixed bus shunts — identical to MATPOWER's treatment.
1164        if gi.abs() > 1e-12 || bi.abs() > 1e-12 {
1165            terminal_shunts.push(RawShunt {
1166                bus: from_bus,
1167                status: 1,
1168                gl: gi * sbase,
1169                bl: bi * sbase,
1170            });
1171        }
1172        if gj.abs() > 1e-12 || bj.abs() > 1e-12 {
1173            terminal_shunts.push(RawShunt {
1174                bus: to_bus,
1175                status: 1,
1176                gl: gj * sbase,
1177                bl: bj * sbase,
1178            });
1179        }
1180        let st = if fields.len() > 13 {
1181            parse_status(&fields[13], line_num, "ST")?
1182        } else {
1183            1
1184        };
1185
1186        branches.push(Branch {
1187            from_bus,
1188            to_bus,
1189            circuit,
1190            r,
1191            x,
1192            b,
1193            rating_a_mva: rate_a,
1194            rating_b_mva: rate_b,
1195            rating_c_mva: rate_c,
1196            tap: 1.0,
1197            phase_shift_rad: 0.0,
1198            in_service: st > 0,
1199            angle_diff_min_rad: None,
1200            angle_diff_max_rad: None,
1201            g_pi: 0.0,
1202            g_mag: 0.0,
1203            b_mag: 0.0,
1204            tab: None,
1205            owners: parse_multi_owner_fields(&fields, 16),
1206            ..Branch::default()
1207        });
1208
1209        pos += 1;
1210    }
1211
1212    Err(PsseError::UnexpectedEof("Branch Data".into()))
1213}
1214
1215// ---------------------------------------------------------------------------
1216// Transformer Data
1217// ---------------------------------------------------------------------------
1218
1219/// Apply CZ impedance conversion for a transformer winding pair impedance.
1220///
1221/// Returns `(r_pu, x_pu)` on the system MVA base.
1222pub(crate) fn apply_cz_conversion(
1223    r: f64,
1224    x: f64,
1225    mut sbase_winding: f64,
1226    sbase_sys: f64,
1227    cz: u32,
1228) -> (f64, f64) {
1229    // PE-03: guard against division-by-zero when sbase_winding is 0 (malformed records).
1230    // Fall back to sbase_sys so the impedance is treated as already on the system base.
1231    if sbase_winding.abs() < 1e-10 {
1232        tracing::warn!(
1233            sbase_winding,
1234            sbase_sys,
1235            cz,
1236            "transformer sbase_winding is zero; falling back to sbase_sys to avoid NaN"
1237        );
1238        sbase_winding = sbase_sys;
1239    }
1240    // If sbase_sys is also zero (pathological case), return passthrough to avoid NaN.
1241    if sbase_sys.abs() < 1e-10 {
1242        return (0.0, 0.0);
1243    }
1244    match cz {
1245        1 => {
1246            // R/X already in p.u. on system base — use directly
1247            (r, x)
1248        }
1249        2 => {
1250            // R/X in p.u. on winding base MVA — convert to system base
1251            if (sbase_winding - sbase_sys).abs() > 1e-10 {
1252                (r * sbase_sys / sbase_winding, x * sbase_sys / sbase_winding)
1253            } else {
1254                (r, x)
1255            }
1256        }
1257        3 => {
1258            // R in watts (load loss), X in impedance magnitude percent on winding base.
1259            // R_pu = R_watts / (1e6 * sbase_winding), then scale to system base.
1260            let r_pu = r / (1_000_000.0 * sbase_winding);
1261            let x_mag = x / 100.0; // from percent
1262            let x_pu = if x_mag * x_mag > r_pu * r_pu {
1263                (x_mag * x_mag - r_pu * r_pu).sqrt()
1264            } else {
1265                x_mag
1266            };
1267            (
1268                r_pu * sbase_sys / sbase_winding,
1269                x_pu * sbase_sys / sbase_winding,
1270            )
1271        }
1272        _ => (r, x),
1273    }
1274}
1275
1276/// Compute a single winding tap in p.u. of the bus base kV.
1277///
1278/// `windv`: the WINDV value from the PSS/E record.
1279/// `nomv`: the NOMV value (kV); 0 means "use bus base kV".
1280/// `bus_bkv`: the bus base kV from the Bus Data section.
1281/// `cw`: CW code (1=pu of bkv, 2=kV, 3=pu of nomv).
1282pub(crate) fn compute_winding_tap_pu(windv: f64, nomv: f64, bus_bkv: f64, cw: u32) -> f64 {
1283    match cw {
1284        1 => windv, // already in p.u. of bus base kV
1285        2 => {
1286            // WINDV in kV — normalise by bus base kV
1287            if bus_bkv > 0.0 {
1288                windv / bus_bkv
1289            } else {
1290                windv
1291            }
1292        }
1293        3 => {
1294            // WINDV in p.u. of NOMV; NOMV in kV (0 means use bus base kV)
1295            let n = if nomv > 0.0 { nomv } else { bus_bkv };
1296            if bus_bkv > 0.0 {
1297                windv * n / bus_bkv
1298            } else {
1299                windv
1300            }
1301        }
1302        _ => windv,
1303    }
1304}
1305
1306/// Build a Branch struct literal with all required fields defaulted for a transformer winding.
1307#[allow(clippy::too_many_arguments)]
1308pub(crate) fn make_xfmr_branch(
1309    from_bus: u32,
1310    to_bus: u32,
1311    circuit: String,
1312    r: f64,
1313    x: f64,
1314    rating_a_mva: f64,
1315    rating_b_mva: f64,
1316    rating_c_mva: f64,
1317    tap: f64,
1318    phase_shift_rad: f64,
1319    in_service: bool,
1320    g_mag: f64,
1321    b_mag: f64,
1322) -> Branch {
1323    Branch {
1324        from_bus,
1325        to_bus,
1326        circuit,
1327        r,
1328        x: if x.abs() < 1e-10 {
1329            if x < 0.0 { -1e-6 } else { 1e-6 }
1330        } else {
1331            x
1332        },
1333        b: 0.0,
1334        rating_a_mva,
1335        rating_b_mva,
1336        rating_c_mva,
1337        tap,
1338        phase_shift_rad: phase_shift_rad.to_radians(),
1339        in_service,
1340        angle_diff_min_rad: None,
1341        angle_diff_max_rad: None,
1342        g_pi: 0.0,
1343        g_mag,
1344        b_mag,
1345        tab: None,
1346        branch_type: BranchType::Transformer,
1347        ..Branch::default()
1348    }
1349}
1350
1351/// Apply discrete tap/phase step data from per-winding COD/RMA/RMI/NTP fields to a Branch.
1352fn apply_3w_step_data(br: &mut Branch, cod: i32, rma: f64, rmi: f64, ntp: u32) {
1353    let range = (rma - rmi).abs();
1354    let n = if ntp > 0 { ntp as f64 } else { 32.0 };
1355    match cod.abs() {
1356        1 | 2 => {
1357            let ts = range / n;
1358            if ts > 1e-9 {
1359                let ctrl = br.opf_control.get_or_insert_with(BranchOpfControl::default);
1360                ctrl.tap_step = ts;
1361                ctrl.tap_min = rmi.min(rma);
1362                ctrl.tap_max = rmi.max(rma);
1363            }
1364        }
1365        3 => {
1366            let sd = range / n;
1367            if sd > 1e-9 {
1368                let ctrl = br.opf_control.get_or_insert_with(BranchOpfControl::default);
1369                ctrl.phase_step_rad = sd.to_radians();
1370                ctrl.phase_min_rad = rmi.min(rma).to_radians();
1371                ctrl.phase_max_rad = rmi.max(rma).to_radians();
1372            }
1373        }
1374        _ => {}
1375    }
1376}
1377
1378type TransformerSectionResult = (Vec<Branch>, Vec<Bus>, Vec<OltcSpec>, Vec<ParSpec>, usize);
1379
1380fn parse_transformer_section(
1381    lines: &[&str],
1382    start: usize,
1383    sbase: f64,
1384    _version: u32,
1385    bus_basekv: &HashMap<u32, f64>,
1386) -> Result<TransformerSectionResult, PsseError> {
1387    let mut transformers: Vec<Branch> = Vec::new();
1388    let mut star_buses: Vec<Bus> = Vec::new();
1389    let mut oltc_specs: Vec<OltcSpec> = Vec::new();
1390    let mut par_specs: Vec<ParSpec> = Vec::new();
1391    let mut pos = start;
1392
1393    // Assign unique bus numbers to fictitious 3-winding star buses beyond all
1394    // existing bus numbers.
1395    let mut max_bus_num: u32 = bus_basekv.keys().copied().max().unwrap_or(0);
1396
1397    while pos < lines.len() {
1398        let line = lines[pos].trim();
1399        if is_section_end(line) {
1400            return Ok((transformers, star_buses, oltc_specs, par_specs, pos + 1));
1401        }
1402        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
1403            return Ok((transformers, star_buses, oltc_specs, par_specs, pos + 1));
1404        }
1405        if line.starts_with("@!") {
1406            pos += 1;
1407            continue;
1408        }
1409
1410        // Record 1: I, J, K, CKT, CW, CZ, CM, MAG1, MAG2, NMETR, 'NAME', STAT, ...
1411        let rec1_fields = tokenize_record(line);
1412        if rec1_fields.is_empty() {
1413            pos += 1;
1414            continue;
1415        }
1416
1417        let line_num = pos + 1;
1418        if rec1_fields.len() < 12 {
1419            return Err(PsseError::Parse {
1420                line: line_num,
1421                message: "truncated transformer record 1".into(),
1422            });
1423        }
1424
1425        let from_bus = parse_f64(&rec1_fields[0], line_num, "I")? as u32;
1426        let to_bus = (parse_f64(&rec1_fields[1], line_num, "J")? as i64).unsigned_abs() as u32;
1427        let k = parse_f64(&rec1_fields[2], line_num, "K")? as i64;
1428        let circuit = unquote(&rec1_fields[3]);
1429        let cw = if rec1_fields.len() > 4 {
1430            parse_f64(&rec1_fields[4], line_num, "CW")? as u32
1431        } else {
1432            1
1433        };
1434        let cz = if rec1_fields.len() > 5 {
1435            parse_f64(&rec1_fields[5], line_num, "CZ")? as u32
1436        } else {
1437            1
1438        };
1439        // fields[6]=CM, [7]=MAG1 (magnetizing conductance pu), [8]=MAG2 (magnetizing susceptance pu)
1440        let mag1 = if rec1_fields.len() > 7 {
1441            parse_f64(&rec1_fields[7], line_num, "MAG1").unwrap_or(0.0)
1442        } else {
1443            0.0
1444        };
1445        let mag2 = if rec1_fields.len() > 8 {
1446            parse_f64(&rec1_fields[8], line_num, "MAG2").unwrap_or(0.0)
1447        } else {
1448            0.0
1449        };
1450        // fields[9]=NMETR, [10]=NAME
1451        let stat = if rec1_fields.len() > 11 {
1452            parse_status(&rec1_fields[11], line_num, "STAT")?
1453        } else {
1454            return Err(PsseError::Parse {
1455                line: line_num,
1456                message: "missing required transformer status (STAT) field".into(),
1457            });
1458        };
1459
1460        let xfmr_owners = parse_multi_owner_fields(&rec1_fields, 12);
1461
1462        let is_3winding = k != 0;
1463        let k_bus = k.unsigned_abs() as u32;
1464
1465        // Record 2: R1-2, X1-2, SBASE1-2 [, R2-3, X2-3, SBASE2-3, R3-1, X3-1, SBASE3-1, VMSTAR, ANSTAR]
1466        pos += 1;
1467        if pos >= lines.len() {
1468            return Err(PsseError::UnexpectedEof("Transformer Record 2".into()));
1469        }
1470        // PE-02: if the next line is already a section terminator the multi-line
1471        // transformer record was truncated; skip this transformer to avoid consuming
1472        // lines that belong to the next section.
1473        {
1474            let peek = lines[pos].trim();
1475            if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
1476                tracing::warn!(
1477                    line = pos + 1,
1478                    from_bus,
1479                    to_bus,
1480                    "Truncated transformer Record 2 at line {}; skipping transformer.",
1481                    pos + 1
1482                );
1483                return Err(PsseError::UnexpectedEof("Transformer Record 2".into()));
1484            }
1485        }
1486        let rec2_fields = tokenize_record(lines[pos].trim());
1487        let line_num2 = pos + 1;
1488
1489        let r12_raw = if !rec2_fields.is_empty() {
1490            parse_f64(&rec2_fields[0], line_num2, "R1-2")?
1491        } else {
1492            0.0
1493        };
1494        let x12_raw = if rec2_fields.len() > 1 {
1495            parse_f64(&rec2_fields[1], line_num2, "X1-2")?
1496        } else {
1497            0.01
1498        };
1499        let sbase12 = if rec2_fields.len() > 2 {
1500            parse_f64(&rec2_fields[2], line_num2, "SBASE1-2")?
1501        } else {
1502            sbase
1503        };
1504
1505        let (r12, x12) = apply_cz_conversion(r12_raw, x12_raw, sbase12, sbase, cz);
1506
1507        if is_3winding {
1508            tracing::warn!(
1509                from_bus,
1510                to_bus,
1511                k_bus,
1512                "3-winding transformer modeled via star (Y) bus expansion; \
1513                 fictitious internal bus inserted, tap ratios and magnetizing \
1514                 admittance applied to winding 1 only"
1515            );
1516            // ---------------------------------------------------------------
1517            // 3-winding transformer: star (Y) topology expansion
1518            // ---------------------------------------------------------------
1519            // Parse additional winding pairs from Record 2 (3-winding format):
1520            //   R2-3, X2-3, SBASE2-3, R3-1, X3-1, SBASE3-1, VMSTAR, ANSTAR
1521            let r23_raw = if rec2_fields.len() > 3 {
1522                parse_f64(&rec2_fields[3], line_num2, "R2-3").unwrap_or(0.0)
1523            } else {
1524                0.0
1525            };
1526            let x23_raw = if rec2_fields.len() > 4 {
1527                parse_f64(&rec2_fields[4], line_num2, "X2-3").unwrap_or(0.01)
1528            } else {
1529                0.01
1530            };
1531            let sbase23 = if rec2_fields.len() > 5 {
1532                parse_f64(&rec2_fields[5], line_num2, "SBASE2-3").unwrap_or(sbase)
1533            } else {
1534                sbase
1535            };
1536            let r31_raw = if rec2_fields.len() > 6 {
1537                parse_f64(&rec2_fields[6], line_num2, "R3-1").unwrap_or(0.0)
1538            } else {
1539                0.0
1540            };
1541            let x31_raw = if rec2_fields.len() > 7 {
1542                parse_f64(&rec2_fields[7], line_num2, "X3-1").unwrap_or(0.01)
1543            } else {
1544                0.01
1545            };
1546            let sbase31 = if rec2_fields.len() > 8 {
1547                parse_f64(&rec2_fields[8], line_num2, "SBASE3-1").unwrap_or(sbase)
1548            } else {
1549                sbase
1550            };
1551            let vmstar = if rec2_fields.len() > 9 {
1552                parse_f64(&rec2_fields[9], line_num2, "VMSTAR").unwrap_or(1.0)
1553            } else {
1554                1.0
1555            };
1556            let anstar_deg = if rec2_fields.len() > 10 {
1557                parse_f64(&rec2_fields[10], line_num2, "ANSTAR").unwrap_or(0.0)
1558            } else {
1559                0.0
1560            };
1561
1562            let (r23, x23) = apply_cz_conversion(r23_raw, x23_raw, sbase23, sbase, cz);
1563            let (r31, x31) = apply_cz_conversion(r31_raw, x31_raw, sbase31, sbase, cz);
1564
1565            // Star-delta impedance conversion:
1566            //   Z1 = (Z12 + Z31 - Z23) / 2
1567            //   Z2 = (Z12 + Z23 - Z31) / 2
1568            //   Z3 = (Z23 + Z31 - Z12) / 2
1569            let r1 = (r12 + r31 - r23) / 2.0;
1570            let x1 = (x12 + x31 - x23) / 2.0;
1571            let r2 = (r12 + r23 - r31) / 2.0;
1572            let x2 = (x12 + x23 - x31) / 2.0;
1573            let r3 = (r23 + r31 - r12) / 2.0;
1574            let x3 = (x23 + x31 - x12) / 2.0;
1575
1576            // Record 3: winding 1 (I bus — from_bus)
1577            pos += 1;
1578            if pos >= lines.len() {
1579                return Err(PsseError::UnexpectedEof("Transformer Record 3 (3W)".into()));
1580            }
1581            // PE-02: truncated 3W record — section end encountered before Record 3.
1582            {
1583                let peek = lines[pos].trim();
1584                if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
1585                    tracing::warn!(
1586                        line = pos + 1,
1587                        from_bus,
1588                        to_bus,
1589                        k_bus,
1590                        "Truncated 3W transformer Record 3 at line {}; skipping transformer.",
1591                        pos + 1
1592                    );
1593                    return Err(PsseError::UnexpectedEof("Transformer Record 3 (3W)".into()));
1594                }
1595            }
1596            let rec3_fields = tokenize_record(lines[pos].trim());
1597            let line_num3 = pos + 1;
1598            let windv1 = if !rec3_fields.is_empty() {
1599                parse_f64(&rec3_fields[0], line_num3, "WINDV1")?
1600            } else {
1601                1.0
1602            };
1603            let nomv1 = if rec3_fields.len() > 1 {
1604                parse_f64(&rec3_fields[1], line_num3, "NOMV1").unwrap_or(0.0)
1605            } else {
1606                0.0
1607            };
1608            let ang1 = if rec3_fields.len() > 2 {
1609                parse_f64(&rec3_fields[2], line_num3, "ANG1").unwrap_or(0.0)
1610            } else {
1611                0.0
1612            };
1613            let rata1 = if rec3_fields.len() > 3 {
1614                parse_f64(&rec3_fields[3], line_num3, "RATA1").unwrap_or(0.0)
1615            } else {
1616                0.0
1617            };
1618            let ratb1 = if rec3_fields.len() > 4 {
1619                parse_f64(&rec3_fields[4], line_num3, "RATB1").unwrap_or(0.0)
1620            } else {
1621                0.0
1622            };
1623            let ratc1 = if rec3_fields.len() > 5 {
1624                parse_f64(&rec3_fields[5], line_num3, "RATC1").unwrap_or(0.0)
1625            } else {
1626                0.0
1627            };
1628
1629            // Parse winding-1 control fields (COD1/NTP1/RMA1/RMI1) from Record 3.
1630            // Same layout as 2W Record 3: fields [6..12].
1631            let cod1_3w = if rec3_fields.len() > 6 {
1632                parse_f64(&rec3_fields[6], line_num3, "COD1").unwrap_or(0.0) as i32
1633            } else {
1634                0
1635            };
1636            let rma1_3w = if rec3_fields.len() > 8 {
1637                parse_f64(&rec3_fields[8], line_num3, "RMA1").unwrap_or(1.1)
1638            } else {
1639                1.1
1640            };
1641            let rmi1_3w = if rec3_fields.len() > 9 {
1642                parse_f64(&rec3_fields[9], line_num3, "RMI1").unwrap_or(0.9)
1643            } else {
1644                0.9
1645            };
1646            let ntp1_3w = if rec3_fields.len() > 12 {
1647                parse_f64(&rec3_fields[12], line_num3, "NTP1").unwrap_or(33.0) as u32
1648            } else {
1649                33
1650            };
1651
1652            // Record 4: winding 2 (J bus — to_bus)
1653            pos += 1;
1654            if pos >= lines.len() {
1655                return Err(PsseError::UnexpectedEof("Transformer Record 4 (3W)".into()));
1656            }
1657            // PE-02: truncated 3W record — section end encountered before Record 4.
1658            {
1659                let peek = lines[pos].trim();
1660                if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
1661                    tracing::warn!(
1662                        line = pos + 1,
1663                        from_bus,
1664                        to_bus,
1665                        k_bus,
1666                        "Truncated 3W transformer Record 4 at line {}; skipping transformer.",
1667                        pos + 1
1668                    );
1669                    return Err(PsseError::UnexpectedEof("Transformer Record 4 (3W)".into()));
1670                }
1671            }
1672            let rec4_fields = tokenize_record(lines[pos].trim());
1673            let line_num4 = pos + 1;
1674            let windv2 = if !rec4_fields.is_empty() {
1675                parse_f64(&rec4_fields[0], line_num4, "WINDV2")?
1676            } else {
1677                1.0
1678            };
1679            let nomv2 = if rec4_fields.len() > 1 {
1680                parse_f64(&rec4_fields[1], line_num4, "NOMV2").unwrap_or(0.0)
1681            } else {
1682                0.0
1683            };
1684            let ang2 = if rec4_fields.len() > 2 {
1685                parse_f64(&rec4_fields[2], line_num4, "ANG2").unwrap_or(0.0)
1686            } else {
1687                0.0
1688            };
1689            let rata2 = if rec4_fields.len() > 3 {
1690                parse_f64(&rec4_fields[3], line_num4, "RATA2").unwrap_or(0.0)
1691            } else {
1692                0.0
1693            };
1694            let ratb2 = if rec4_fields.len() > 4 {
1695                parse_f64(&rec4_fields[4], line_num4, "RATB2").unwrap_or(0.0)
1696            } else {
1697                0.0
1698            };
1699            let ratc2 = if rec4_fields.len() > 5 {
1700                parse_f64(&rec4_fields[5], line_num4, "RATC2").unwrap_or(0.0)
1701            } else {
1702                0.0
1703            };
1704            // Winding-2 control fields (COD2/NTP2/RMA2/RMI2).
1705            let cod2_3w = if rec4_fields.len() > 6 {
1706                parse_f64(&rec4_fields[6], line_num4, "COD2").unwrap_or(0.0) as i32
1707            } else {
1708                0
1709            };
1710            let rma2_3w = if rec4_fields.len() > 8 {
1711                parse_f64(&rec4_fields[8], line_num4, "RMA2").unwrap_or(1.1)
1712            } else {
1713                1.1
1714            };
1715            let rmi2_3w = if rec4_fields.len() > 9 {
1716                parse_f64(&rec4_fields[9], line_num4, "RMI2").unwrap_or(0.9)
1717            } else {
1718                0.9
1719            };
1720            let ntp2_3w = if rec4_fields.len() > 12 {
1721                parse_f64(&rec4_fields[12], line_num4, "NTP2").unwrap_or(33.0) as u32
1722            } else {
1723                33
1724            };
1725
1726            // Record 5: winding 3 (K bus — k_bus)
1727            pos += 1;
1728            if pos >= lines.len() {
1729                return Err(PsseError::UnexpectedEof("Transformer Record 5 (3W)".into()));
1730            }
1731            // PE-02: truncated 3W record — section end encountered before Record 5.
1732            {
1733                let peek = lines[pos].trim();
1734                if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
1735                    tracing::warn!(
1736                        line = pos + 1,
1737                        from_bus,
1738                        to_bus,
1739                        k_bus,
1740                        "Truncated 3W transformer Record 5 at line {}; skipping transformer.",
1741                        pos + 1
1742                    );
1743                    return Err(PsseError::UnexpectedEof("Transformer Record 5 (3W)".into()));
1744                }
1745            }
1746            let rec5_fields = tokenize_record(lines[pos].trim());
1747            let line_num5 = pos + 1;
1748            let windv3 = if !rec5_fields.is_empty() {
1749                parse_f64(&rec5_fields[0], line_num5, "WINDV3")?
1750            } else {
1751                1.0
1752            };
1753            let nomv3 = if rec5_fields.len() > 1 {
1754                parse_f64(&rec5_fields[1], line_num5, "NOMV3").unwrap_or(0.0)
1755            } else {
1756                0.0
1757            };
1758            let ang3 = if rec5_fields.len() > 2 {
1759                parse_f64(&rec5_fields[2], line_num5, "ANG3").unwrap_or(0.0)
1760            } else {
1761                0.0
1762            };
1763            let rata3 = if rec5_fields.len() > 3 {
1764                parse_f64(&rec5_fields[3], line_num5, "RATA3").unwrap_or(0.0)
1765            } else {
1766                0.0
1767            };
1768            let ratb3 = if rec5_fields.len() > 4 {
1769                parse_f64(&rec5_fields[4], line_num5, "RATB3").unwrap_or(0.0)
1770            } else {
1771                0.0
1772            };
1773            let ratc3 = if rec5_fields.len() > 5 {
1774                parse_f64(&rec5_fields[5], line_num5, "RATC3").unwrap_or(0.0)
1775            } else {
1776                0.0
1777            };
1778            // Winding-3 control fields (COD3/NTP3/RMA3/RMI3).
1779            let cod3_3w = if rec5_fields.len() > 6 {
1780                parse_f64(&rec5_fields[6], line_num5, "COD3").unwrap_or(0.0) as i32
1781            } else {
1782                0
1783            };
1784            let rma3_3w = if rec5_fields.len() > 8 {
1785                parse_f64(&rec5_fields[8], line_num5, "RMA3").unwrap_or(1.1)
1786            } else {
1787                1.1
1788            };
1789            let rmi3_3w = if rec5_fields.len() > 9 {
1790                parse_f64(&rec5_fields[9], line_num5, "RMI3").unwrap_or(0.9)
1791            } else {
1792                0.9
1793            };
1794            let ntp3_3w = if rec5_fields.len() > 12 {
1795                parse_f64(&rec5_fields[12], line_num5, "NTP3").unwrap_or(33.0) as u32
1796            } else {
1797                33
1798            };
1799
1800            // Compute individual winding taps in p.u. of their respective bus base kV
1801            let bkv1 = bus_basekv.get(&from_bus).copied().unwrap_or(1.0);
1802            let bkv2 = bus_basekv.get(&to_bus).copied().unwrap_or(1.0);
1803            let bkv3 = bus_basekv.get(&k_bus).copied().unwrap_or(1.0);
1804            let tap1 = compute_winding_tap_pu(windv1, nomv1, bkv1, cw);
1805            let tap2 = compute_winding_tap_pu(windv2, nomv2, bkv2, cw);
1806            let tap3 = compute_winding_tap_pu(windv3, nomv3, bkv3, cw);
1807
1808            // Create fictitious star bus
1809            max_bus_num += 1;
1810            let star_bus_num = max_bus_num;
1811            star_buses.push(Bus {
1812                number: star_bus_num,
1813                name: format!("STAR_{from_bus}_{to_bus}_{k_bus}"),
1814                bus_type: BusType::PQ,
1815                shunt_conductance_mw: 0.0,
1816                shunt_susceptance_mvar: 0.0,
1817                area: 1,
1818                voltage_magnitude_pu: vmstar,
1819                voltage_angle_rad: anstar_deg.to_radians(),
1820                base_kv: bkv1.max(bkv2).max(bkv3).max(1.0), // fictitious node: use highest winding kV to avoid div-by-zero in fault analysis
1821                zone: 1,
1822                voltage_max_pu: 1.1,
1823                voltage_min_pu: 0.9,
1824                island_id: 0,
1825                latitude: None,
1826                longitude: None,
1827                ..Bus::new(0, BusType::PQ, 0.0)
1828            });
1829
1830            let in_service = stat > 0;
1831
1832            // Branch I (from_bus) → star: winding-1 impedance, tap1, shift=ang1
1833            // Magnetizing admittance (MAG1/MAG2) applied at winding-1 terminal.
1834            let mut w1 = make_xfmr_branch(
1835                from_bus,
1836                star_bus_num,
1837                circuit.clone(),
1838                r1,
1839                x1,
1840                rata1,
1841                ratb1,
1842                ratc1,
1843                tap1,
1844                ang1,
1845                in_service,
1846                mag1,
1847                mag2,
1848            );
1849            w1.branch_type = BranchType::Transformer3W;
1850            w1.owners = xfmr_owners.clone();
1851            apply_3w_step_data(&mut w1, cod1_3w, rma1_3w, rmi1_3w, ntp1_3w);
1852            transformers.push(w1);
1853
1854            // Branch J (to_bus) → star: winding-2 impedance, tap2, shift=ang2
1855            let mut w2 = make_xfmr_branch(
1856                to_bus,
1857                star_bus_num,
1858                circuit.clone(),
1859                r2,
1860                x2,
1861                rata2,
1862                ratb2,
1863                ratc2,
1864                tap2,
1865                ang2,
1866                in_service,
1867                0.0,
1868                0.0,
1869            );
1870            w2.branch_type = BranchType::Transformer3W;
1871            w2.owners = xfmr_owners.clone();
1872            apply_3w_step_data(&mut w2, cod2_3w, rma2_3w, rmi2_3w, ntp2_3w);
1873            transformers.push(w2);
1874
1875            // Branch K (k_bus) → star: winding-3 impedance, tap3, shift=ang3
1876            let mut w3 = make_xfmr_branch(
1877                k_bus,
1878                star_bus_num,
1879                circuit,
1880                r3,
1881                x3,
1882                rata3,
1883                ratb3,
1884                ratc3,
1885                tap3,
1886                ang3,
1887                in_service,
1888                0.0,
1889                0.0,
1890            );
1891            w3.branch_type = BranchType::Transformer3W;
1892            w3.owners = xfmr_owners.clone();
1893            apply_3w_step_data(&mut w3, cod3_3w, rma3_3w, rmi3_3w, ntp3_3w);
1894            transformers.push(w3);
1895
1896            tracing::debug!(
1897                from_bus,
1898                to_bus,
1899                k_bus,
1900                star_bus = star_bus_num,
1901                "3-winding transformer expanded to star topology"
1902            );
1903        } else {
1904            // ---------------------------------------------------------------
1905            // 2-winding transformer
1906            // ---------------------------------------------------------------
1907
1908            // Record 3: WINDV1, NOMV1, ANG1, RATA1, RATB1, RATC1, ...
1909            pos += 1;
1910            if pos >= lines.len() {
1911                return Err(PsseError::UnexpectedEof("Transformer Record 3".into()));
1912            }
1913            // PE-02: truncated 2W record — section end encountered before Record 3.
1914            {
1915                let peek = lines[pos].trim();
1916                if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
1917                    tracing::warn!(
1918                        line = pos + 1,
1919                        from_bus,
1920                        to_bus,
1921                        "Truncated 2W transformer Record 3 at line {}; skipping transformer.",
1922                        pos + 1
1923                    );
1924                    return Err(PsseError::UnexpectedEof("Transformer Record 3".into()));
1925                }
1926            }
1927            let rec3_fields = tokenize_record(lines[pos].trim());
1928            let line_num3 = pos + 1;
1929
1930            let windv1 = if !rec3_fields.is_empty() {
1931                parse_f64(&rec3_fields[0], line_num3, "WINDV1")?
1932            } else {
1933                1.0
1934            };
1935            let nomv1 = if rec3_fields.len() > 1 {
1936                parse_f64(&rec3_fields[1], line_num3, "NOMV1")?
1937            } else {
1938                0.0
1939            };
1940            let ang1 = if rec3_fields.len() > 2 {
1941                parse_f64(&rec3_fields[2], line_num3, "ANG1")?
1942            } else {
1943                0.0
1944            };
1945            let rata1 = if rec3_fields.len() > 3 {
1946                parse_f64(&rec3_fields[3], line_num3, "RATA1")?
1947            } else {
1948                0.0
1949            };
1950            let ratb1 = if rec3_fields.len() > 4 {
1951                parse_f64(&rec3_fields[4], line_num3, "RATB1")?
1952            } else {
1953                0.0
1954            };
1955            let ratc1 = if rec3_fields.len() > 5 {
1956                parse_f64(&rec3_fields[5], line_num3, "RATC1")?
1957            } else {
1958                0.0
1959            };
1960            // Record 3 fields: ... COD1[6], CONT1[7], RMA1[8], RMI1[9], VMA1[10], VMI1[11], NTP1[12], TAB1[13]
1961            let cod1 = if rec3_fields.len() > 6 {
1962                parse_f64(&rec3_fields[6], line_num3, "COD1").unwrap_or(0.0) as i32
1963            } else {
1964                0
1965            };
1966            let cont1 = if rec3_fields.len() > 7 {
1967                parse_f64(&rec3_fields[7], line_num3, "CONT1").unwrap_or(0.0) as i32
1968            } else {
1969                0
1970            };
1971            let rma1 = if rec3_fields.len() > 8 {
1972                parse_f64(&rec3_fields[8], line_num3, "RMA1").unwrap_or(1.1)
1973            } else {
1974                1.1
1975            };
1976            let rmi1 = if rec3_fields.len() > 9 {
1977                parse_f64(&rec3_fields[9], line_num3, "RMI1").unwrap_or(0.9)
1978            } else {
1979                0.9
1980            };
1981            let vma1 = if rec3_fields.len() > 10 {
1982                parse_f64(&rec3_fields[10], line_num3, "VMA1").unwrap_or(1.1)
1983            } else {
1984                1.1
1985            };
1986            let vmi1 = if rec3_fields.len() > 11 {
1987                parse_f64(&rec3_fields[11], line_num3, "VMI1").unwrap_or(0.9)
1988            } else {
1989                0.9
1990            };
1991            let ntp1 = if rec3_fields.len() > 12 {
1992                parse_f64(&rec3_fields[12], line_num3, "NTP1").unwrap_or(33.0) as u32
1993            } else {
1994                33
1995            };
1996
1997            // Build OLTC / PAR specs from COD1 control code.
1998            //   COD1 = 1,2   → OLTC voltage/reactive control
1999            //   COD1 = 3     → PAR active power flow control
2000            //   COD1 = 0,-1  → fixed tap / load-drop (no outer-loop spec needed)
2001            match cod1 {
2002                1 | 2 | -1 | -2 => {
2003                    // Voltage-magnitude or reactive-power control (OLTC).
2004                    // CONT1 is the regulated bus (negative = remote, positive = local,
2005                    // 0 = from-bus default; PSS/E convention: negative means from-bus side).
2006                    let regulated_bus = cont1.unsigned_abs(); // 0 → local (to-bus)
2007                    let v_target = (vma1 + vmi1) * 0.5;
2008                    let v_band = (vma1 - vmi1).abs().max(0.001); // at least 0.1% band
2009                    // ntp1 steps over the full [rmi1, rma1] range
2010                    let tap_range = (rma1 - rmi1).abs();
2011                    let tap_step = if ntp1 > 0 {
2012                        tap_range / ntp1 as f64
2013                    } else {
2014                        tap_range / 32.0 // sensible default
2015                    };
2016                    if tap_step > 1e-9 {
2017                        oltc_specs.push(OltcSpec {
2018                            from_bus,
2019                            to_bus,
2020                            circuit: circuit.to_string(),
2021                            regulated_bus,
2022                            v_target,
2023                            v_band,
2024                            tap_min: rmi1.min(rma1),
2025                            tap_max: rmi1.max(rma1),
2026                            tap_step,
2027                        });
2028                    }
2029                }
2030                3 | -3 => {
2031                    // Active power flow control (Phase Angle Regulator).
2032                    // CONT1 is the monitored branch from-bus (negative = to-bus side).
2033                    // VMA1/VMI1 are target flow bounds in MW (for COD1=3).
2034                    // RMA1/RMI1 are angle bounds in degrees.
2035                    let monitored_from_bus = cont1.unsigned_abs();
2036                    let p_min_mw = vmi1.min(vma1);
2037                    let p_max_mw = vmi1.max(vma1);
2038                    let p_target_mw = (p_min_mw + p_max_mw) * 0.5;
2039                    let p_band_mw = (p_max_mw - p_min_mw).max(1.0); // at least 1 MW band
2040                    let ang_range = (rma1 - rmi1).abs();
2041                    let ang_step_deg = if ntp1 > 0 {
2042                        ang_range / ntp1 as f64
2043                    } else {
2044                        ang_range / 32.0
2045                    };
2046                    if ang_step_deg > 1e-9 {
2047                        par_specs.push(ParSpec {
2048                            from_bus,
2049                            to_bus,
2050                            circuit: circuit.to_string(),
2051                            // Monitored branch from-bus from CONT1 (0 = monitor PAR itself)
2052                            monitored_from_bus,
2053                            monitored_to_bus: 0, // not specified in PSS/E 2W record
2054                            monitored_circuit: "1".to_string(),
2055                            p_target_mw,
2056                            p_band_mw,
2057                            angle_min_deg: rmi1.min(rma1),
2058                            angle_max_deg: rmi1.max(rma1),
2059                            ang_step_deg,
2060                        });
2061                    }
2062                }
2063                _ => {} // COD1 = 0: fixed tap, no outer-loop control
2064            }
2065
2066            let tab1: Option<u32> = if rec3_fields.len() > 13 {
2067                let v = parse_f64(&rec3_fields[13], line_num3, "TAB1").unwrap_or(0.0) as i32;
2068                if v > 0 { Some(v as u32) } else { None }
2069            } else {
2070                None
2071            };
2072
2073            // Record 4: WINDV2, NOMV2, ANG2, ...
2074            pos += 1;
2075            if pos >= lines.len() {
2076                return Err(PsseError::UnexpectedEof("Transformer Record 4".into()));
2077            }
2078            // PE-02: truncated 2W record — section end encountered before Record 4.
2079            {
2080                let peek = lines[pos].trim();
2081                if is_section_end(peek) || peek.starts_with('Q') || peek.eq_ignore_ascii_case("q") {
2082                    tracing::warn!(
2083                        line = pos + 1,
2084                        from_bus,
2085                        to_bus,
2086                        "Truncated 2W transformer Record 4 at line {}; skipping transformer.",
2087                        pos + 1
2088                    );
2089                    return Err(PsseError::UnexpectedEof("Transformer Record 4".into()));
2090                }
2091            }
2092            let rec4_fields = tokenize_record(lines[pos].trim());
2093            let line_num4 = pos + 1;
2094
2095            let windv2 = if !rec4_fields.is_empty() {
2096                parse_f64(&rec4_fields[0], line_num4, "WINDV2")?
2097            } else {
2098                1.0
2099            };
2100
2101            // Compute 2-winding tap ratio based on CW code
2102            let tap = match cw {
2103                1 => {
2104                    // WINDV in p.u. of bus base kV — tap = windv1/windv2
2105                    if windv2 != 0.0 {
2106                        windv1 / windv2
2107                    } else {
2108                        windv1
2109                    }
2110                }
2111                2 => {
2112                    // WINDV in kV — normalise by bus base kV
2113                    let bkv1 = bus_basekv.get(&from_bus).copied().unwrap_or(1.0);
2114                    let bkv2 = bus_basekv.get(&to_bus).copied().unwrap_or(1.0);
2115                    let t1 = if bkv1 > 0.0 { windv1 / bkv1 } else { windv1 };
2116                    let t2 = if bkv2 > 0.0 { windv2 / bkv2 } else { windv2 };
2117                    if t2 != 0.0 { t1 / t2 } else { t1 }
2118                }
2119                3 => {
2120                    // WINDV in p.u. of NOMV; NOMV in kV (0 → use bus base kV)
2121                    let bkv1 = bus_basekv.get(&from_bus).copied().unwrap_or(1.0);
2122                    let bkv2 = bus_basekv.get(&to_bus).copied().unwrap_or(1.0);
2123                    let n1 = if nomv1 > 0.0 { nomv1 } else { bkv1 };
2124                    let nomv2 = if rec4_fields.len() > 1 {
2125                        parse_f64(&rec4_fields[1], line_num4, "NOMV2").unwrap_or(0.0)
2126                    } else {
2127                        0.0
2128                    };
2129                    let n2 = if nomv2 > 0.0 { nomv2 } else { bkv2 };
2130                    let t1 = if bkv1 > 0.0 {
2131                        windv1 * n1 / bkv1
2132                    } else {
2133                        windv1
2134                    };
2135                    let t2 = if bkv2 > 0.0 {
2136                        windv2 * n2 / bkv2
2137                    } else {
2138                        windv2
2139                    };
2140                    if t2 != 0.0 { t1 / t2 } else { t1 }
2141                }
2142                _ => windv1,
2143            };
2144
2145            // shift stored in degrees (MATPOWER convention)
2146            let mut xfmr = make_xfmr_branch(
2147                from_bus,
2148                to_bus,
2149                circuit,
2150                r12,
2151                x12,
2152                rata1,
2153                ratb1,
2154                ratc1,
2155                tap,
2156                ang1,
2157                stat > 0,
2158                mag1,
2159                mag2,
2160            );
2161            xfmr.tab = tab1;
2162            xfmr.owners = xfmr_owners.clone();
2163            // Populate discrete step sizes on the Branch from Record 3 tap range data.
2164            // COD1=1,2 (OLTC): tap_step from NTP1 over [RMI1, RMA1] ratio range.
2165            // COD1=3 (PAR): phase_step_rad from NTP1 over [RMI1, RMA1] angle range.
2166            match cod1.abs() {
2167                1 | 2 => {
2168                    let tap_range = (rma1 - rmi1).abs();
2169                    let ts = if ntp1 > 0 {
2170                        tap_range / ntp1 as f64
2171                    } else {
2172                        tap_range / 32.0
2173                    };
2174                    if ts > 1e-9 {
2175                        let ctrl = xfmr
2176                            .opf_control
2177                            .get_or_insert_with(BranchOpfControl::default);
2178                        ctrl.tap_step = ts;
2179                        ctrl.tap_min = rmi1.min(rma1);
2180                        ctrl.tap_max = rmi1.max(rma1);
2181                    }
2182                }
2183                3 => {
2184                    let ang_range = (rma1 - rmi1).abs();
2185                    let as_deg = if ntp1 > 0 {
2186                        ang_range / ntp1 as f64
2187                    } else {
2188                        ang_range / 32.0
2189                    };
2190                    if as_deg > 1e-9 {
2191                        let ctrl = xfmr
2192                            .opf_control
2193                            .get_or_insert_with(BranchOpfControl::default);
2194                        ctrl.phase_step_rad = as_deg.to_radians();
2195                        ctrl.phase_min_rad = rmi1.min(rma1).to_radians();
2196                        ctrl.phase_max_rad = rmi1.max(rma1).to_radians();
2197                    }
2198                }
2199                _ => {}
2200            }
2201            transformers.push(xfmr);
2202        }
2203
2204        pos += 1;
2205    }
2206
2207    Err(PsseError::UnexpectedEof("Transformer Data".into()))
2208}
2209
2210// ---------------------------------------------------------------------------
2211// Area Interchange Data (Section 7)
2212// ---------------------------------------------------------------------------
2213
2214/// Parse PSS/E Area Interchange Data section.
2215///
2216/// Record format: `ARNUM, ISW, PDES, PTOL, 'ARNAME'`
2217///
2218/// Returns `(areas, next_pos)`.
2219fn parse_area_schedule_section(
2220    lines: &[&str],
2221    start: usize,
2222) -> Result<(Vec<AreaSchedule>, usize), PsseError> {
2223    let mut areas = Vec::new();
2224    let mut pos = start;
2225
2226    while pos < lines.len() {
2227        let line = lines[pos].trim();
2228
2229        if is_section_end(line) {
2230            return Ok((areas, pos + 1));
2231        }
2232        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
2233            return Ok((areas, pos + 1));
2234        }
2235        if line.is_empty() || line.starts_with("@!") {
2236            pos += 1;
2237            continue;
2238        }
2239
2240        let fields = tokenize_record(line);
2241        if fields.is_empty() {
2242            pos += 1;
2243            continue;
2244        }
2245        if fields.len() < 5 {
2246            return Err(PsseError::Parse {
2247                line: pos + 1,
2248                message: "area interchange record is truncated".into(),
2249            });
2250        }
2251
2252        let line_num = pos + 1;
2253
2254        let number = parse_f64(&fields[0], line_num, "ARNUM")? as u32;
2255        let slack_bus = parse_f64(&fields[1], line_num, "ISW")? as u32;
2256        let p_desired_mw = parse_f64(&fields[2], line_num, "PDES")?;
2257        let p_tolerance_mw = parse_f64(&fields[3], line_num, "PTOL")?;
2258        let name = unquote(&fields[4]);
2259
2260        areas.push(AreaSchedule {
2261            number,
2262            slack_bus,
2263            p_desired_mw,
2264            p_tolerance_mw,
2265            name,
2266        });
2267
2268        pos += 1;
2269    }
2270
2271    Ok((areas, pos))
2272}
2273
2274// ---------------------------------------------------------------------------
2275// Two-Terminal DC Line Data (Section 8)
2276// ---------------------------------------------------------------------------
2277
2278/// Parse PSS/E Two-Terminal DC Line Data section.
2279///
2280/// Each DC line has 3 records:
2281///   Record 1: `NAME, MDC, RDC, SETVL, VSCHD, VCMOD, RCOMP, DELTI, METER, DCVMIN, CCCITMX, CCCACC`
2282///   Record 2 (rectifier): `IPR, NBR, ALFMX, ALFMN, RCR, XCR, EBASR, TRR, TAPR, TMXR, TMNR, STPR, ICR, IFR, ITR, IDR, XCAPR`
2283///   Record 3 (inverter):  `IPI, NBI, GAMMX, GAMMN, RCI, XCI, EBASI, TRI, TAPI, TMXI, TMNI, STPI, ICI, IFI, ITI, IDI, XCAPI`
2284///
2285/// Returns `(lcc_links, next_pos)`.
2286fn parse_dc_line_section(
2287    lines: &[&str],
2288    start: usize,
2289) -> Result<(Vec<LccHvdcLink>, usize), PsseError> {
2290    let mut lcc_links = Vec::new();
2291    let mut pos = start;
2292
2293    while pos < lines.len() {
2294        // --- Record 1 ---
2295        let line1 = lines[pos].trim();
2296
2297        if is_section_end(line1) {
2298            return Ok((lcc_links, pos + 1));
2299        }
2300        if line1.starts_with('Q') || line1.eq_ignore_ascii_case("q") {
2301            return Ok((lcc_links, pos + 1));
2302        }
2303        if line1.is_empty() || line1.starts_with("@!") {
2304            pos += 1;
2305            continue;
2306        }
2307
2308        let f1 = tokenize_record(line1);
2309        if f1.is_empty() {
2310            pos += 1;
2311            continue;
2312        }
2313        if f1.len() < 12 {
2314            return Err(PsseError::Parse {
2315                line: pos + 1,
2316                message: format!(
2317                    "truncated PSS/E two-terminal DC record: expected at least 12 fields, got {}",
2318                    f1.len()
2319                ),
2320            });
2321        }
2322
2323        let line_num = pos + 1;
2324
2325        let name = unquote(&f1[0]);
2326        let mdc = parse_required_f64(&f1[1], line_num, "MDC")? as u32;
2327        let resistance_ohm = parse_required_f64(&f1[2], line_num, "RDC")?;
2328        let setvl = parse_required_f64(&f1[3], line_num, "SETVL")?;
2329        let vschd = parse_required_f64(&f1[4], line_num, "VSCHD")?;
2330        let vcmod = if f1.len() > 5 {
2331            parse_f64(&f1[5], line_num, "VCMOD").unwrap_or(0.0)
2332        } else {
2333            0.0
2334        };
2335        let rcomp = if f1.len() > 6 {
2336            parse_f64(&f1[6], line_num, "RCOMP").unwrap_or(0.0)
2337        } else {
2338            0.0
2339        };
2340        let delti = if f1.len() > 7 {
2341            parse_f64(&f1[7], line_num, "DELTI").unwrap_or(0.0)
2342        } else {
2343            0.0
2344        };
2345        let meter = if f1.len() > 8 {
2346            unquote(&f1[8]).chars().next().unwrap_or('I')
2347        } else {
2348            'I'
2349        };
2350        let dcvmin = if f1.len() > 9 {
2351            parse_f64(&f1[9], line_num, "DCVMIN").unwrap_or(0.0)
2352        } else {
2353            0.0
2354        };
2355        let cccitmx = if f1.len() > 10 {
2356            parse_f64(&f1[10], line_num, "CCCITMX").unwrap_or(20.0) as u32
2357        } else {
2358            20
2359        };
2360        let cccacc = if f1.len() > 11 {
2361            parse_f64(&f1[11], line_num, "CCCACC").unwrap_or(1.0)
2362        } else {
2363            1.0
2364        };
2365
2366        pos += 1;
2367
2368        // --- Record 2 (rectifier) ---
2369        if pos >= lines.len() {
2370            break;
2371        }
2372        // Skip @! annotations between records
2373        while pos < lines.len()
2374            && (lines[pos].trim().is_empty() || lines[pos].trim().starts_with("@!"))
2375        {
2376            pos += 1;
2377        }
2378        if pos >= lines.len() {
2379            break;
2380        }
2381
2382        let f2 = tokenize_record(lines[pos].trim());
2383        let line_num2 = pos + 1;
2384        let rectifier = parse_dc_converter_record(&f2, line_num2)?;
2385        pos += 1;
2386
2387        // --- Record 3 (inverter) ---
2388        if pos >= lines.len() {
2389            break;
2390        }
2391        while pos < lines.len()
2392            && (lines[pos].trim().is_empty() || lines[pos].trim().starts_with("@!"))
2393        {
2394            pos += 1;
2395        }
2396        if pos >= lines.len() {
2397            break;
2398        }
2399
2400        let f3 = tokenize_record(lines[pos].trim());
2401        let line_num3 = pos + 1;
2402        let inverter = parse_dc_converter_record(&f3, line_num3)?;
2403        pos += 1;
2404
2405        lcc_links.push(LccHvdcLink {
2406            name,
2407            mode: LccHvdcControlMode::from_u32(mdc),
2408            resistance_ohm,
2409            scheduled_setpoint: setvl,
2410            scheduled_voltage_kv: vschd,
2411            voltage_mode_switch_kv: vcmod,
2412            compounding_resistance_ohm: rcomp,
2413            current_margin_ka: delti,
2414            meter,
2415            voltage_min_kv: dcvmin,
2416            ac_dc_iteration_max: cccitmx,
2417            ac_dc_iteration_acceleration: cccacc,
2418            rectifier,
2419            inverter,
2420            // PSS/E raw data doesn't carry a variable-P range; leave at 0/0
2421            // so the link is treated as fixed at `scheduled_setpoint` by the
2422            // joint AC-DC OPF (caller can set a range later if wanted).
2423            p_dc_min_mw: 0.0,
2424            p_dc_max_mw: 0.0,
2425        });
2426    }
2427
2428    Ok((lcc_links, pos))
2429}
2430
2431/// Parse a PSS/E DC converter record (rectifier or inverter).
2432///
2433/// Fields: `IBUS, NBRIDGES, ANGMAX, ANGMIN, RC, XC, EBASE, TR, TAP, TAPMAX, TAPMIN, TAPSTEP, IC, IF, IT, ID, XCAP`
2434fn parse_dc_converter_record(
2435    fields: &[String],
2436    line_num: usize,
2437) -> Result<LccConverterTerminal, PsseError> {
2438    if fields.len() < 13 {
2439        return Err(PsseError::Parse {
2440            line: line_num,
2441            message: format!(
2442                "truncated PSS/E DC converter record: expected at least 13 fields, got {}",
2443                fields.len()
2444            ),
2445        });
2446    }
2447    let bus = parse_required_f64(&fields[0], line_num, "IBUS")? as u32;
2448    let n_bridges = parse_required_f64(&fields[1], line_num, "NBRIDGES")? as u32;
2449    let alpha_max = parse_required_f64(&fields[2], line_num, "ANGMAX")?;
2450    let alpha_min = parse_required_f64(&fields[3], line_num, "ANGMIN")?;
2451    let r_comm = parse_required_f64(&fields[4], line_num, "RC")?;
2452    let x_comm = parse_required_f64(&fields[5], line_num, "XC")?;
2453    let e_base = parse_required_f64(&fields[6], line_num, "EBASE")?;
2454    let tr = parse_required_f64(&fields[7], line_num, "TR")?;
2455    let tap = parse_required_f64(&fields[8], line_num, "TAP")?;
2456    let tap_max = parse_required_f64(&fields[9], line_num, "TAPMAX")?;
2457    let tap_min = parse_required_f64(&fields[10], line_num, "TAPMIN")?;
2458    let tap_step = parse_required_f64(&fields[11], line_num, "TAPSTEP")?;
2459    let in_service = parse_status(&fields[12], line_num, "IC")? != 0;
2460
2461    Ok(LccConverterTerminal {
2462        bus,
2463        n_bridges,
2464        alpha_max,
2465        alpha_min,
2466        commutation_resistance_ohm: r_comm,
2467        commutation_reactance_ohm: x_comm,
2468        base_voltage_kv: e_base,
2469        turns_ratio: tr,
2470        tap,
2471        tap_max,
2472        tap_min,
2473        tap_step,
2474        in_service,
2475    })
2476}
2477
2478// ---------------------------------------------------------------------------
2479// VSC DC Line Data (Section 9)
2480// ---------------------------------------------------------------------------
2481
2482/// Parse PSS/E VSC DC Line Data section.
2483///
2484/// Each VSC link has 3 records:
2485///   Record 1: `'NAME', MDC, RDC, O1, F1, O2, F2`
2486///   Record 2 (converter 1): `IBUS, TYPE, MODE, DCSET, ACSET, ALOSS, BLOSS, MINLOSS, SMX, SMN, GMX, GMN, BAVAIL, STATE, RMPCT, NREG, VSREG, NREG, VSREF`
2487///   Record 3 (converter 2): same format as Record 2
2488///
2489/// Returns `(vsc_lines, next_pos)`.
2490fn parse_vsc_dc_section(
2491    lines: &[&str],
2492    start: usize,
2493) -> Result<(Vec<VscHvdcLink>, usize), PsseError> {
2494    let mut vsc_lines = Vec::new();
2495    let mut pos = start;
2496
2497    while pos < lines.len() {
2498        // --- Record 1 ---
2499        let line1 = lines[pos].trim();
2500
2501        if is_section_end(line1) {
2502            return Ok((vsc_lines, pos + 1));
2503        }
2504        if line1.starts_with('Q') || line1.eq_ignore_ascii_case("q") {
2505            return Ok((vsc_lines, pos + 1));
2506        }
2507        if line1.is_empty() || line1.starts_with("@!") {
2508            pos += 1;
2509            continue;
2510        }
2511
2512        let f1 = tokenize_record(line1);
2513        if f1.is_empty() {
2514            pos += 1;
2515            continue;
2516        }
2517        if f1.len() < 3 {
2518            return Err(PsseError::Parse {
2519                line: pos + 1,
2520                message: format!(
2521                    "truncated PSS/E VSC DC record: expected at least 3 fields, got {}",
2522                    f1.len()
2523                ),
2524            });
2525        }
2526
2527        let line_num = pos + 1;
2528        let name = unquote(&f1[0]);
2529        let mdc = parse_required_f64(&f1[1], line_num, "MDC")? as u32;
2530        let resistance_ohm = parse_required_f64(&f1[2], line_num, "RDC")?;
2531        pos += 1;
2532
2533        // --- Record 2 (converter 1) ---
2534        while pos < lines.len()
2535            && (lines[pos].trim().is_empty() || lines[pos].trim().starts_with("@!"))
2536        {
2537            pos += 1;
2538        }
2539        if pos >= lines.len() {
2540            break;
2541        }
2542
2543        let f2 = tokenize_record(lines[pos].trim());
2544        let conv1 = parse_vsc_converter_record(&f2, pos + 1)?;
2545        pos += 1;
2546
2547        // --- Record 3 (converter 2) ---
2548        while pos < lines.len()
2549            && (lines[pos].trim().is_empty() || lines[pos].trim().starts_with("@!"))
2550        {
2551            pos += 1;
2552        }
2553        if pos >= lines.len() {
2554            break;
2555        }
2556
2557        let f3 = tokenize_record(lines[pos].trim());
2558        let conv2 = parse_vsc_converter_record(&f3, pos + 1)?;
2559        pos += 1;
2560
2561        vsc_lines.push(VscHvdcLink {
2562            name,
2563            mode: VscHvdcControlMode::from_u32(mdc),
2564            resistance_ohm,
2565            converter1: conv1,
2566            converter2: conv2,
2567        });
2568    }
2569
2570    Ok((vsc_lines, pos))
2571}
2572
2573/// Parse a PSS/E VSC converter record.
2574///
2575/// Fields: `IBUS, TYPE, MODE, DCSET, ACSET, ALOSS, BLOSS, MINLOSS, SMX, SMN, GMX, GMN, BAVAIL, STATE, ...`
2576fn parse_vsc_converter_record(
2577    fields: &[String],
2578    line_num: usize,
2579) -> Result<VscConverterTerminal, PsseError> {
2580    if fields.len() < 14 {
2581        return Err(PsseError::Parse {
2582            line: line_num,
2583            message: format!(
2584                "truncated PSS/E VSC converter record: expected at least 14 fields, got {}",
2585                fields.len()
2586            ),
2587        });
2588    }
2589    let bus = parse_required_f64(&fields[0], line_num, "IBUS")? as u32;
2590    // fields[1] = TYPE (1-phase or 3-phase, not relevant here)
2591    let control_mode = if fields.len() > 2 {
2592        let m = parse_required_f64(&fields[2], line_num, "MODE")? as u32;
2593        VscConverterAcControlMode::from_u32(m)
2594    } else {
2595        VscConverterAcControlMode::ReactivePower
2596    };
2597    let dc_setpoint = if fields.len() > 3 {
2598        parse_required_f64(&fields[3], line_num, "DCSET")?
2599    } else {
2600        return Err(PsseError::Parse {
2601            line: line_num,
2602            message: "missing required DCSET field".into(),
2603        });
2604    };
2605    let ac_setpoint = if fields.len() > 4 {
2606        parse_required_f64(&fields[4], line_num, "ACSET")?
2607    } else {
2608        return Err(PsseError::Parse {
2609            line: line_num,
2610            message: "missing required ACSET field".into(),
2611        });
2612    };
2613    let loss_a = if fields.len() > 5 {
2614        parse_required_f64(&fields[5], line_num, "ALOSS")?
2615    } else {
2616        return Err(PsseError::Parse {
2617            line: line_num,
2618            message: "missing required ALOSS field".into(),
2619        });
2620    };
2621    let loss_b = if fields.len() > 6 {
2622        parse_required_f64(&fields[6], line_num, "BLOSS")?
2623    } else {
2624        return Err(PsseError::Parse {
2625            line: line_num,
2626            message: "missing required BLOSS field".into(),
2627        });
2628    };
2629    // fields[7] = MINLOSS (minimum loss — informational only)
2630    let q_max = if fields.len() > 8 {
2631        parse_f64(&fields[8], line_num, "SMX").unwrap_or(9999.0)
2632    } else {
2633        9999.0
2634    };
2635    let q_min = if fields.len() > 9 {
2636        parse_f64(&fields[9], line_num, "SMN").unwrap_or(-9999.0)
2637    } else {
2638        -9999.0
2639    };
2640    let v_max = if fields.len() > 10 {
2641        parse_f64(&fields[10], line_num, "GMX").unwrap_or(1.1)
2642    } else {
2643        1.1
2644    };
2645    let v_min = if fields.len() > 11 {
2646        parse_required_f64(&fields[11], line_num, "GMN")?
2647    } else {
2648        return Err(PsseError::Parse {
2649            line: line_num,
2650            message: "missing required GMN field".into(),
2651        });
2652    };
2653    // fields[12] = BAVAIL, fields[13] = STATE (1 = in-service)
2654    let in_service = if fields.len() > 13 {
2655        parse_status(&fields[13], line_num, "STATE")? != 0
2656    } else {
2657        return Err(PsseError::Parse {
2658            line: line_num,
2659            message: "missing required STATE field".into(),
2660        });
2661    };
2662
2663    Ok(VscConverterTerminal {
2664        bus,
2665        control_mode,
2666        dc_setpoint,
2667        ac_setpoint,
2668        loss_constant_mw: loss_a,
2669        loss_linear: loss_b,
2670        q_min_mvar: q_min,
2671        q_max_mvar: q_max,
2672        voltage_min_pu: v_min,
2673        voltage_max_pu: v_max,
2674        in_service,
2675    })
2676}
2677
2678// ---------------------------------------------------------------------------
2679// FACTS Device Data (Section 14)
2680// ---------------------------------------------------------------------------
2681
2682/// Parse PSS/E FACTS Device Data section.
2683///
2684/// Record format (one record per device):
2685///   `'NAME', I, J, MODE, PDES, QDES, VSET, SHMX, TRMX, VTMN, VTMX, VSMX, IMX, LINX, RMPCT, OWNER, SET1, SET2, VSREF, REMOT, MNAME`
2686///
2687/// Returns `(facts_devices, next_pos)`.
2688fn parse_facts_section(lines: &[&str], start: usize) -> (Vec<FactsDevice>, usize) {
2689    let mut devices = Vec::new();
2690    let mut pos = start;
2691
2692    while pos < lines.len() {
2693        let line = lines[pos].trim();
2694
2695        if is_section_end(line) {
2696            return (devices, pos + 1);
2697        }
2698        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
2699            return (devices, pos + 1);
2700        }
2701        if line.is_empty() || line.starts_with("@!") {
2702            pos += 1;
2703            continue;
2704        }
2705
2706        let fields = tokenize_record(line);
2707        if fields.is_empty() {
2708            pos += 1;
2709            continue;
2710        }
2711
2712        let line_num = pos + 1;
2713
2714        let name = unquote(&fields[0]);
2715        let bus_i = if fields.len() > 1 {
2716            parse_f64(&fields[1], line_num, "I").unwrap_or(0.0) as u32
2717        } else {
2718            0
2719        };
2720        let bus_j = if fields.len() > 2 {
2721            parse_f64(&fields[2], line_num, "J").unwrap_or(0.0) as u32
2722        } else {
2723            0
2724        };
2725        let mode_val = if fields.len() > 3 {
2726            parse_f64(&fields[3], line_num, "MODE").unwrap_or(0.0) as u32
2727        } else {
2728            0
2729        };
2730        let p_des = if fields.len() > 4 {
2731            parse_f64(&fields[4], line_num, "PDES").unwrap_or(0.0)
2732        } else {
2733            0.0
2734        };
2735        let q_des = if fields.len() > 5 {
2736            parse_f64(&fields[5], line_num, "QDES").unwrap_or(0.0)
2737        } else {
2738            0.0
2739        };
2740        let v_set = if fields.len() > 6 {
2741            parse_f64(&fields[6], line_num, "VSET").unwrap_or(1.0)
2742        } else {
2743            1.0
2744        };
2745        let q_max = if fields.len() > 7 {
2746            parse_f64(&fields[7], line_num, "SHMX").unwrap_or(9999.0)
2747        } else {
2748            9999.0
2749        };
2750        // fields[8] = TRMX (max transformer turns ratio — informational)
2751        // fields[9] = VTMN, fields[10] = VTMX (voltage limits at controlled bus)
2752        // fields[11] = VSMX (max series voltage — informational)
2753        // fields[12] = IMX (max current — informational)
2754        let linx = if fields.len() > 13 {
2755            parse_f64(&fields[13], line_num, "LINX").unwrap_or(0.0)
2756        } else {
2757            0.0
2758        };
2759
2760        let mode = FactsMode::from_u32(mode_val);
2761        let in_service = mode.in_service();
2762
2763        // Infer FACTS device type from operating mode:
2764        //   ShuntOnly → SVC (shunt reactive compensation)
2765        //   SeriesOnly / ImpedanceModulation → TCSC (series impedance control)
2766        //   ShuntSeries / SeriesPowerControl → UPFC (combined shunt + series)
2767        let facts_type = match mode {
2768            FactsMode::ShuntOnly => FactsType::Svc,
2769            FactsMode::SeriesOnly | FactsMode::ImpedanceModulation => FactsType::Tcsc,
2770            FactsMode::ShuntSeries | FactsMode::SeriesPowerControl => FactsType::Upfc,
2771            FactsMode::OutOfService => FactsType::Svc, // default for OOS devices
2772        };
2773
2774        devices.push(FactsDevice {
2775            name,
2776            bus_from: bus_i,
2777            bus_to: bus_j,
2778            mode,
2779            p_setpoint_mw: p_des,
2780            q_setpoint_mvar: q_des,
2781            voltage_setpoint_pu: v_set,
2782            q_max,
2783            series_reactance_pu: linx,
2784            in_service,
2785            facts_type,
2786            ..FactsDevice::default()
2787        });
2788
2789        pos += 1;
2790    }
2791
2792    (devices, pos)
2793}
2794
2795// ---------------------------------------------------------------------------
2796// Tokenizer — handles comma and/or whitespace-delimited records with quoted strings
2797// ---------------------------------------------------------------------------
2798
2799fn tokenize_record(line: &str) -> Vec<String> {
2800    let line = line.trim();
2801    // Strip trailing comment (everything after / that's not inside quotes)
2802    let line = strip_comment(line);
2803    let line = line.trim();
2804    if line.is_empty() {
2805        return Vec::new();
2806    }
2807
2808    if line.contains(',') {
2809        // Comma-delimited: split by commas, respecting quoted strings
2810        let mut tokens = Vec::new();
2811        let mut current = String::new();
2812        let mut in_quotes = false;
2813
2814        for ch in line.chars() {
2815            match ch {
2816                '\'' => {
2817                    in_quotes = !in_quotes;
2818                    current.push(ch);
2819                }
2820                ',' if !in_quotes => {
2821                    tokens.push(current.trim().to_string());
2822                    current.clear();
2823                }
2824                _ => current.push(ch),
2825            }
2826        }
2827        // Push last field
2828        let last = current.trim().to_string();
2829        if !last.is_empty() {
2830            tokens.push(last);
2831        }
2832        tokens
2833    } else {
2834        // Whitespace-delimited (no commas in record)
2835        let mut tokens = Vec::new();
2836        let mut chars = line.chars().peekable();
2837
2838        while let Some(&ch) = chars.peek() {
2839            if ch == '\'' {
2840                // Quoted string
2841                chars.next();
2842                let mut s = String::new();
2843                while let Some(&c) = chars.peek() {
2844                    if c == '\'' {
2845                        chars.next();
2846                        break;
2847                    }
2848                    s.push(c);
2849                    chars.next();
2850                }
2851                tokens.push(format!("'{s}'"));
2852            } else if ch == ' ' || ch == '\t' {
2853                chars.next();
2854            } else {
2855                let mut tok = String::new();
2856                while let Some(&c) = chars.peek() {
2857                    if c == ' ' || c == '\t' {
2858                        break;
2859                    }
2860                    tok.push(c);
2861                    chars.next();
2862                }
2863                tokens.push(tok);
2864            }
2865        }
2866        tokens
2867    }
2868}
2869
2870/// Strip the trailing comment from a PSS/E record line.
2871/// Comments start with `/` but we must not strip `/` inside quoted strings.
2872fn strip_comment(line: &str) -> &str {
2873    let mut in_quotes = false;
2874    for (i, ch) in line.char_indices() {
2875        match ch {
2876            '\'' => in_quotes = !in_quotes,
2877            '/' if !in_quotes => return &line[..i],
2878            _ => {}
2879        }
2880    }
2881    line
2882}
2883
2884/// Check if a line is a PSS/E section terminator.
2885///
2886/// Fix PE-01: the only valid terminators are the bare `"0"` token or a line
2887/// starting with `"0 /"` (i.e. `"0 / END OF …"`).  The old code also matched
2888/// `"0,"` and `"0\t"` / `"0 "` which incorrectly terminated sections when a
2889/// branch or transformer had bus 0 as its from-bus.
2890fn is_section_end(line: &str) -> bool {
2891    let line = line.trim();
2892    if line == "0" {
2893        return true;
2894    }
2895    if line.starts_with("0 /") || line.starts_with("0\t/") {
2896        return true;
2897    }
2898    false
2899}
2900
2901/// Parse up to 4 (Oi, Fi) owner pairs from a PSS/E record.
2902fn parse_multi_owner_fields(
2903    fields: &[String],
2904    start_idx: usize,
2905) -> Vec<surge_network::network::OwnershipEntry> {
2906    let mut owners = Vec::new();
2907    for i in 0..4 {
2908        let oi_idx = start_idx + i * 2;
2909        let fi_idx = oi_idx + 1;
2910        if fields.len() <= oi_idx {
2911            break;
2912        }
2913        let oi = fields[oi_idx]
2914            .trim()
2915            .trim_matches('\'')
2916            .parse::<f64>()
2917            .unwrap_or(0.0) as u32;
2918        if oi == 0 {
2919            break;
2920        }
2921        let fi = if fields.len() > fi_idx {
2922            fields[fi_idx]
2923                .trim()
2924                .trim_matches('\'')
2925                .parse::<f64>()
2926                .unwrap_or(1.0)
2927        } else {
2928            1.0
2929        };
2930        owners.push(surge_network::network::OwnershipEntry {
2931            owner: oi,
2932            fraction: fi,
2933        });
2934    }
2935    owners
2936}
2937
2938/// Parse a token as f64, with helpful error context.
2939fn parse_f64(token: &str, line: usize, field: &str) -> Result<f64, PsseError> {
2940    let token = token.trim();
2941    if token.is_empty() {
2942        return Ok(0.0); // default for missing fields
2943    }
2944    // Remove surrounding quotes
2945    let token = if token.starts_with('\'') && token.ends_with('\'') {
2946        &token[1..token.len() - 1]
2947    } else {
2948        token
2949    };
2950    let token = token.trim();
2951    if token.is_empty() {
2952        return Ok(0.0);
2953    }
2954    let val = token.parse::<f64>().map_err(|_| PsseError::Parse {
2955        line,
2956        message: format!("invalid {field} value: '{token}'"),
2957    })?;
2958    // PE-04: reject NaN / Inf — they propagate silently through power-flow arithmetic.
2959    if !val.is_finite() {
2960        return Err(PsseError::NonFiniteValue {
2961            line,
2962            message: format!("field '{field}' parsed to non-finite value '{token}' (NaN or Inf)"),
2963        });
2964    }
2965    Ok(val)
2966}
2967
2968fn parse_required_f64(token: &str, line: usize, field: &str) -> Result<f64, PsseError> {
2969    let token = token.trim();
2970    if token.is_empty() {
2971        return Err(PsseError::Parse {
2972            line,
2973            message: format!("missing required {field} value"),
2974        });
2975    }
2976    parse_f64(token, line, field)
2977}
2978
2979/// Parse a required PSS/E STATUS field that may be numeric (1/0) or textual ('A'/'I'/'BL').
2980///
2981/// Older PSS/E versions (v29/v30) use text codes:
2982///   'A' (active) or 'I' (in-service) → 1
2983///   'BL' (blank/disconnected), 'O' (out-of-service), '0' → 0
2984///
2985/// Missing or unknown values are rejected so we do not fail open to in-service.
2986fn parse_status(token: &str, line: usize, field: &str) -> Result<i32, PsseError> {
2987    let tok = token.trim().trim_matches('\'').trim_matches('"').trim();
2988    if tok.is_empty() {
2989        return Err(PsseError::Parse {
2990            line,
2991            message: format!("missing required {field} value"),
2992        });
2993    }
2994    // Try numeric first
2995    if let Ok(v) = tok.parse::<f64>() {
2996        return Ok(v as i32);
2997    }
2998    // Text status codes (case-insensitive)
2999    match tok.to_uppercase().as_str() {
3000        "A" | "I" => Ok(1),  // Active / In-service
3001        "BL" | "O" => Ok(0), // Blank / Out-of-service
3002        _ => Err(PsseError::Parse {
3003            line,
3004            message: format!("unknown {field} status code: '{tok}'"),
3005        }),
3006    }
3007}
3008
3009// ---------------------------------------------------------------------------
3010// Zone Data (Section 13)
3011// ---------------------------------------------------------------------------
3012
3013/// Parse PSS/E Zone Data section.
3014///
3015/// Record format: `ZONUM, 'ZONAME'`
3016///
3017/// Returns `(zones, next_pos)`.
3018fn parse_zone_section(lines: &[&str], start: usize) -> (Vec<Region>, usize) {
3019    let mut zones = Vec::new();
3020    let mut pos = start;
3021
3022    while pos < lines.len() {
3023        let line = lines[pos].trim();
3024
3025        if is_section_end(line) {
3026            return (zones, pos + 1);
3027        }
3028        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3029            return (zones, pos + 1);
3030        }
3031        if line.is_empty() || line.starts_with("@!") {
3032            pos += 1;
3033            continue;
3034        }
3035
3036        let fields = tokenize_record(line);
3037        if fields.is_empty() {
3038            pos += 1;
3039            continue;
3040        }
3041
3042        let line_num = pos + 1;
3043
3044        let number = match parse_f64(&fields[0], line_num, "ZONUM") {
3045            Ok(v) => v as u32,
3046            Err(_) => {
3047                pos += 1;
3048                continue;
3049            }
3050        };
3051        let name = if fields.len() > 1 {
3052            unquote(&fields[1])
3053        } else {
3054            String::new()
3055        };
3056
3057        zones.push(Region { number, name });
3058        pos += 1;
3059    }
3060
3061    (zones, pos)
3062}
3063
3064// ---------------------------------------------------------------------------
3065// Owner Data (Section 13c)
3066// ---------------------------------------------------------------------------
3067
3068/// Parse PSS/E Owner Data section.
3069///
3070/// Record format: `OWNUM, 'OWNAME'`
3071///
3072/// Returns `(owners, next_pos)`.
3073fn parse_owner_section(lines: &[&str], start: usize) -> (Vec<Owner>, usize) {
3074    let mut owners = Vec::new();
3075    let mut pos = start;
3076
3077    while pos < lines.len() {
3078        let line = lines[pos].trim();
3079
3080        if is_section_end(line) {
3081            return (owners, pos + 1);
3082        }
3083        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3084            return (owners, pos + 1);
3085        }
3086        if line.is_empty() || line.starts_with("@!") {
3087            pos += 1;
3088            continue;
3089        }
3090
3091        let fields = tokenize_record(line);
3092        if fields.is_empty() {
3093            pos += 1;
3094            continue;
3095        }
3096
3097        let line_num = pos + 1;
3098
3099        let number = match parse_f64(&fields[0], line_num, "OWNUM") {
3100            Ok(v) => v as u32,
3101            Err(_) => {
3102                pos += 1;
3103                continue;
3104            }
3105        };
3106        let name = if fields.len() > 1 {
3107            unquote(&fields[1])
3108        } else {
3109            String::new()
3110        };
3111
3112        owners.push(Owner { number, name });
3113        pos += 1;
3114    }
3115
3116    (owners, pos)
3117}
3118
3119// ---------------------------------------------------------------------------
3120// Inter-Area Transfer Data (Section 13b)
3121// ---------------------------------------------------------------------------
3122
3123/// Parse PSS/E Inter-Area Transfer Data section.
3124///
3125/// Record format: `ARFROM, ARTO, TRID, PTRAN`
3126///
3127/// Returns `(transfers, next_pos)`.
3128fn parse_inter_area_transfer_section(
3129    lines: &[&str],
3130    start: usize,
3131) -> (Vec<ScheduledAreaTransfer>, usize) {
3132    let mut transfers = Vec::new();
3133    let mut pos = start;
3134
3135    while pos < lines.len() {
3136        let line = lines[pos].trim();
3137
3138        if is_section_end(line) {
3139            return (transfers, pos + 1);
3140        }
3141        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3142            return (transfers, pos + 1);
3143        }
3144        if line.is_empty() || line.starts_with("@!") {
3145            pos += 1;
3146            continue;
3147        }
3148
3149        let fields = tokenize_record(line);
3150        if fields.len() < 2 {
3151            pos += 1;
3152            continue;
3153        }
3154
3155        let line_num = pos + 1;
3156
3157        let from_area = match parse_f64(&fields[0], line_num, "ARFROM") {
3158            Ok(v) => v as u32,
3159            Err(_) => {
3160                pos += 1;
3161                continue;
3162            }
3163        };
3164        let to_area = match parse_f64(&fields[1], line_num, "ARTO") {
3165            Ok(v) => v as u32,
3166            Err(_) => {
3167                pos += 1;
3168                continue;
3169            }
3170        };
3171        let id = if fields.len() > 2 {
3172            parse_f64(&fields[2], line_num, "TRID").unwrap_or(1.0) as u32
3173        } else {
3174            1
3175        };
3176        let p_transfer_mw = if fields.len() > 3 {
3177            parse_f64(&fields[3], line_num, "PTRAN").unwrap_or(0.0)
3178        } else {
3179            0.0
3180        };
3181
3182        transfers.push(ScheduledAreaTransfer {
3183            from_area,
3184            to_area,
3185            id,
3186            p_transfer_mw,
3187        });
3188        pos += 1;
3189    }
3190
3191    (transfers, pos)
3192}
3193
3194// ---------------------------------------------------------------------------
3195// Impedance Correction Data (Section 10)
3196// ---------------------------------------------------------------------------
3197
3198/// Parse PSS/E Impedance Correction Data section.
3199///
3200/// Record format: `I, T1, F1, T2, F2, ..., T11, F11`
3201///
3202/// The first field is the table number, then up to 11 (T, F) pairs.
3203/// Pairs where both T and F are 0.0 are skipped.
3204///
3205/// Returns `(tables, next_pos)`.
3206fn parse_impedance_correction_section(
3207    lines: &[&str],
3208    start: usize,
3209) -> (Vec<ImpedanceCorrectionTable>, usize) {
3210    let mut tables = Vec::new();
3211    let mut pos = start;
3212
3213    while pos < lines.len() {
3214        let line = lines[pos].trim();
3215
3216        if is_section_end(line) {
3217            return (tables, pos + 1);
3218        }
3219        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3220            return (tables, pos + 1);
3221        }
3222        if line.is_empty() || line.starts_with("@!") {
3223            pos += 1;
3224            continue;
3225        }
3226
3227        let fields = tokenize_record(line);
3228        if fields.is_empty() {
3229            pos += 1;
3230            continue;
3231        }
3232
3233        let line_num = pos + 1;
3234
3235        let number = match parse_f64(&fields[0], line_num, "I") {
3236            Ok(v) => v as u32,
3237            Err(_) => {
3238                pos += 1;
3239                continue;
3240            }
3241        };
3242
3243        let mut entries = Vec::new();
3244        // Fields 1..N are T1,F1,T2,F2,... pairs
3245        let mut i = 1;
3246        while i + 1 < fields.len() {
3247            let t = parse_f64(&fields[i], line_num, "T").unwrap_or(0.0);
3248            let f = parse_f64(&fields[i + 1], line_num, "F").unwrap_or(0.0);
3249            if t != 0.0 || f != 0.0 {
3250                entries.push((t, f));
3251            }
3252            i += 2;
3253        }
3254
3255        tables.push(ImpedanceCorrectionTable { number, entries });
3256        pos += 1;
3257    }
3258
3259    (tables, pos)
3260}
3261
3262// ---------------------------------------------------------------------------
3263// Multi-Section Line Data (Section 12)
3264// ---------------------------------------------------------------------------
3265
3266/// Parse PSS/E Multi-Section Line Data section.
3267///
3268/// Record format: `I, J, 'ID', MET, DUM1, DUM2, ...`
3269///
3270/// Variable-length dummy bus list after the first 4 fields.
3271///
3272/// Returns `(groups, next_pos)`.
3273fn parse_multi_section_line_section(
3274    lines: &[&str],
3275    start: usize,
3276) -> (Vec<MultiSectionLineGroup>, usize) {
3277    let mut groups = Vec::new();
3278    let mut pos = start;
3279
3280    while pos < lines.len() {
3281        let line = lines[pos].trim();
3282
3283        if is_section_end(line) {
3284            return (groups, pos + 1);
3285        }
3286        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3287            return (groups, pos + 1);
3288        }
3289        if line.is_empty() || line.starts_with("@!") {
3290            pos += 1;
3291            continue;
3292        }
3293
3294        let fields = tokenize_record(line);
3295        if fields.len() < 3 {
3296            pos += 1;
3297            continue;
3298        }
3299
3300        let line_num = pos + 1;
3301
3302        let from_bus = match parse_f64(&fields[0], line_num, "I") {
3303            Ok(v) => v as u32,
3304            Err(_) => {
3305                pos += 1;
3306                continue;
3307            }
3308        };
3309        let to_bus = match parse_f64(&fields[1], line_num, "J") {
3310            Ok(v) => v as u32,
3311            Err(_) => {
3312                pos += 1;
3313                continue;
3314            }
3315        };
3316        let id = unquote(&fields[2]);
3317        let metered_end = if fields.len() > 3 {
3318            parse_f64(&fields[3], line_num, "MET").unwrap_or(1.0) as u32
3319        } else {
3320            1
3321        };
3322
3323        // Remaining fields are dummy bus numbers
3324        let mut dummy_buses = Vec::new();
3325        for fi in 4..fields.len() {
3326            let bus = parse_f64(&fields[fi], line_num, "DUM").unwrap_or(0.0) as u32;
3327            if bus != 0 {
3328                dummy_buses.push(bus);
3329            }
3330        }
3331
3332        groups.push(MultiSectionLineGroup {
3333            from_bus,
3334            to_bus,
3335            id,
3336            metered_end,
3337            dummy_buses,
3338        });
3339        pos += 1;
3340    }
3341
3342    (groups, pos)
3343}
3344
3345// ---------------------------------------------------------------------------
3346// Multi-Terminal DC Data (Section 11)
3347// ---------------------------------------------------------------------------
3348
3349/// Parse PSS/E Multi-Terminal DC Data section.
3350///
3351/// Each system starts with a header line, followed by NCONV converter records,
3352/// NDCBS DC bus records, and NDCLN DC link records. The section ends with `0`.
3353///
3354/// Header: `'NAME', NCONV, NDCBS, NDCLN, MDC, VCONV, VCMOD, VCONVN`
3355/// Converter: `IB, N, ANGMX, ANGMN, RC, XC, EBAS, TR, TAP, TPMX, TPMN, TSTP, SETVL, DCPF, MARG, CNVCOD`
3356/// DC bus: `IDC, IB, AREA, ZONE, 'DCNAME', IDC2, RGRND, OWNER`
3357/// DC link: `IDC, JDC, 'DCCKT', MET, RDC, LDC`
3358///
3359/// Returns `(mt_lines, next_pos)`.
3360fn parse_multi_terminal_dc_section(lines: &[&str], start: usize) -> (Vec<RawMtdcSystem>, usize) {
3361    let mut mt_lines = Vec::new();
3362    let mut pos = start;
3363
3364    while pos < lines.len() {
3365        let line = lines[pos].trim();
3366
3367        if is_section_end(line) {
3368            return (mt_lines, pos + 1);
3369        }
3370        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3371            return (mt_lines, pos + 1);
3372        }
3373        if line.is_empty() || line.starts_with("@!") {
3374            pos += 1;
3375            continue;
3376        }
3377
3378        // Parse header line
3379        let fields = tokenize_record(line);
3380        if fields.is_empty() {
3381            pos += 1;
3382            continue;
3383        }
3384
3385        let line_num = pos + 1;
3386
3387        let name = unquote(&fields[0]);
3388        let nconv = if fields.len() > 1 {
3389            parse_f64(&fields[1], line_num, "NCONV").unwrap_or(0.0) as u32
3390        } else {
3391            0
3392        };
3393        let ndcbs = if fields.len() > 2 {
3394            parse_f64(&fields[2], line_num, "NDCBS").unwrap_or(0.0) as u32
3395        } else {
3396            0
3397        };
3398        let ndcln = if fields.len() > 3 {
3399            parse_f64(&fields[3], line_num, "NDCLN").unwrap_or(0.0) as u32
3400        } else {
3401            0
3402        };
3403        let mdc = if fields.len() > 4 {
3404            parse_f64(&fields[4], line_num, "MDC").unwrap_or(1.0) as u32
3405        } else {
3406            1
3407        };
3408        let vconv = if fields.len() > 5 {
3409            parse_f64(&fields[5], line_num, "VCONV").unwrap_or(0.0)
3410        } else {
3411            0.0
3412        };
3413        let vcmod = if fields.len() > 6 {
3414            parse_f64(&fields[6], line_num, "VCMOD").unwrap_or(0.0)
3415        } else {
3416            0.0
3417        };
3418        let vconvn = if fields.len() > 7 {
3419            parse_f64(&fields[7], line_num, "VCONVN").unwrap_or(0.0)
3420        } else {
3421            0.0
3422        };
3423
3424        pos += 1;
3425
3426        // Parse NCONV converter records
3427        let mut converters = Vec::new();
3428        while converters.len() < nconv as usize && pos < lines.len() {
3429            let cline = lines[pos].trim();
3430            if cline.is_empty() || cline.starts_with("@!") {
3431                pos += 1;
3432                continue;
3433            }
3434            let cf = tokenize_record(cline);
3435            let cl = pos + 1;
3436
3437            let bus = if !cf.is_empty() {
3438                parse_f64(&cf[0], cl, "IB").unwrap_or(0.0) as u32
3439            } else {
3440                0
3441            };
3442            let n_bridges = if cf.len() > 1 {
3443                parse_f64(&cf[1], cl, "N").unwrap_or(1.0) as u32
3444            } else {
3445                1
3446            };
3447            let alpha_max = if cf.len() > 2 {
3448                parse_f64(&cf[2], cl, "ANGMX").unwrap_or(90.0)
3449            } else {
3450                90.0
3451            };
3452            let alpha_min = if cf.len() > 3 {
3453                parse_f64(&cf[3], cl, "ANGMN").unwrap_or(5.0)
3454            } else {
3455                5.0
3456            };
3457            let r_comm = if cf.len() > 4 {
3458                parse_f64(&cf[4], cl, "RC").unwrap_or(0.0)
3459            } else {
3460                0.0
3461            };
3462            let x_comm = if cf.len() > 5 {
3463                parse_f64(&cf[5], cl, "XC").unwrap_or(0.0)
3464            } else {
3465                0.0
3466            };
3467            let e_base = if cf.len() > 6 {
3468                parse_f64(&cf[6], cl, "EBAS").unwrap_or(0.0)
3469            } else {
3470                0.0
3471            };
3472            let tr = if cf.len() > 7 {
3473                parse_f64(&cf[7], cl, "TR").unwrap_or(1.0)
3474            } else {
3475                1.0
3476            };
3477            let tap = if cf.len() > 8 {
3478                parse_f64(&cf[8], cl, "TAP").unwrap_or(1.0)
3479            } else {
3480                1.0
3481            };
3482            let tap_max = if cf.len() > 9 {
3483                parse_f64(&cf[9], cl, "TPMX").unwrap_or(1.1)
3484            } else {
3485                1.1
3486            };
3487            let tap_min = if cf.len() > 10 {
3488                parse_f64(&cf[10], cl, "TPMN").unwrap_or(0.9)
3489            } else {
3490                0.9
3491            };
3492            let tap_step = if cf.len() > 11 {
3493                parse_f64(&cf[11], cl, "TSTP").unwrap_or(0.00625)
3494            } else {
3495                0.00625
3496            };
3497            let setvl = if cf.len() > 12 {
3498                parse_f64(&cf[12], cl, "SETVL").unwrap_or(0.0)
3499            } else {
3500                0.0
3501            };
3502            let dcpf = if cf.len() > 13 {
3503                parse_f64(&cf[13], cl, "DCPF").unwrap_or(0.0)
3504            } else {
3505                0.0
3506            };
3507            let marg = if cf.len() > 14 {
3508                parse_f64(&cf[14], cl, "MARG").unwrap_or(0.0)
3509            } else {
3510                0.0
3511            };
3512            let cnvcod = if cf.len() > 15 {
3513                parse_f64(&cf[15], cl, "CNVCOD").unwrap_or(1.0) as u32
3514            } else {
3515                1
3516            };
3517
3518            converters.push(RawMtdcConverter {
3519                bus,
3520                n_bridges,
3521                alpha_max,
3522                alpha_min,
3523                commutation_resistance_ohm: r_comm,
3524                commutation_reactance_ohm: x_comm,
3525                base_voltage_kv: e_base,
3526                turns_ratio: tr,
3527                tap,
3528                tap_max,
3529                tap_min,
3530                tap_step,
3531                scheduled_setpoint: setvl,
3532                dcpf,
3533                marg,
3534                cnvcod,
3535            });
3536            pos += 1;
3537        }
3538
3539        // Parse NDCBS DC bus records
3540        let mut dc_buses = Vec::new();
3541        while dc_buses.len() < ndcbs as usize && pos < lines.len() {
3542            let bline = lines[pos].trim();
3543            if bline.is_empty() || bline.starts_with("@!") {
3544                pos += 1;
3545                continue;
3546            }
3547            let bf = tokenize_record(bline);
3548            let bl = pos + 1;
3549
3550            let dc_bus = if !bf.is_empty() {
3551                parse_f64(&bf[0], bl, "IDC").unwrap_or(0.0) as u32
3552            } else {
3553                0
3554            };
3555            let ac_bus = if bf.len() > 1 {
3556                parse_f64(&bf[1], bl, "IB").unwrap_or(0.0) as u32
3557            } else {
3558                0
3559            };
3560            let area = if bf.len() > 2 {
3561                parse_f64(&bf[2], bl, "AREA").unwrap_or(1.0) as u32
3562            } else {
3563                1
3564            };
3565            let zone = if bf.len() > 3 {
3566                parse_f64(&bf[3], bl, "ZONE").unwrap_or(1.0) as u32
3567            } else {
3568                1
3569            };
3570            let dc_name = if bf.len() > 4 {
3571                unquote(&bf[4])
3572            } else {
3573                String::new()
3574            };
3575            let idc2 = if bf.len() > 5 {
3576                parse_f64(&bf[5], bl, "IDC2").unwrap_or(0.0) as u32
3577            } else {
3578                0
3579            };
3580            let rgrnd = if bf.len() > 6 {
3581                parse_f64(&bf[6], bl, "RGRND").unwrap_or(0.0)
3582            } else {
3583                0.0
3584            };
3585            let owner = if bf.len() > 7 {
3586                parse_f64(&bf[7], bl, "OWNER").unwrap_or(1.0) as u32
3587            } else {
3588                1
3589            };
3590
3591            dc_buses.push(RawMtdcBus {
3592                dc_bus,
3593                ac_bus,
3594                area,
3595                zone,
3596                name: dc_name,
3597                idc2,
3598                rgrnd,
3599                owner,
3600            });
3601            pos += 1;
3602        }
3603
3604        // Parse NDCLN DC link records
3605        let mut dc_links = Vec::new();
3606        while dc_links.len() < ndcln as usize && pos < lines.len() {
3607            let lline = lines[pos].trim();
3608            if lline.is_empty() || lline.starts_with("@!") {
3609                pos += 1;
3610                continue;
3611            }
3612            let lf = tokenize_record(lline);
3613            let ll = pos + 1;
3614
3615            let from_dc_bus = if !lf.is_empty() {
3616                parse_f64(&lf[0], ll, "IDC").unwrap_or(0.0) as u32
3617            } else {
3618                0
3619            };
3620            let to_dc_bus = if lf.len() > 1 {
3621                parse_f64(&lf[1], ll, "JDC").unwrap_or(0.0) as u32
3622            } else {
3623                0
3624            };
3625            let circuit = if lf.len() > 2 {
3626                unquote(&lf[2])
3627            } else {
3628                "1".to_string()
3629            };
3630            let metered = if lf.len() > 3 {
3631                parse_f64(&lf[3], ll, "MET").unwrap_or(1.0) as u32
3632            } else {
3633                1
3634            };
3635            let resistance_ohm = if lf.len() > 4 {
3636                parse_f64(&lf[4], ll, "RDC").unwrap_or(0.0)
3637            } else {
3638                0.0
3639            };
3640            let ldc = if lf.len() > 5 {
3641                parse_f64(&lf[5], ll, "LDC").unwrap_or(0.0)
3642            } else {
3643                0.0
3644            };
3645
3646            dc_links.push(RawMtdcLink {
3647                from_dc_bus,
3648                to_dc_bus,
3649                circuit,
3650                metered,
3651                resistance_ohm,
3652                ldc,
3653            });
3654            pos += 1;
3655        }
3656
3657        mt_lines.push(RawMtdcSystem {
3658            name,
3659            n_converters: nconv,
3660            n_dc_buses: ndcbs,
3661            n_dc_links: ndcln,
3662            control_mode: mdc,
3663            dc_voltage_kv: vconv,
3664            voltage_mode_switch_kv: vcmod,
3665            dc_voltage_min_kv: vconvn,
3666            converters,
3667            dc_buses,
3668            dc_links,
3669        });
3670    }
3671
3672    (mt_lines, pos)
3673}
3674
3675fn normalize_dc_grids(network: &mut Network, systems: &[RawMtdcSystem]) {
3676    let mut next_grid = network.hvdc.next_dc_grid_id();
3677    let mut next_dc_bus_id = network.hvdc.next_dc_bus_id();
3678
3679    for system in systems {
3680        if system.control_mode == 0 {
3681            continue;
3682        }
3683
3684        let grid_id = next_grid;
3685        next_grid += 1;
3686
3687        let base_kv_dc = if system.dc_voltage_kv > 0.0 {
3688            system.dc_voltage_kv
3689        } else {
3690            system
3691                .converters
3692                .iter()
3693                .find(|converter| converter.base_voltage_kv > 0.0)
3694                .map(|converter| converter.base_voltage_kv)
3695                .unwrap_or(500.0)
3696        };
3697
3698        let mut local_to_global: HashMap<u32, u32> = HashMap::new();
3699        for dc_bus in &system.dc_buses {
3700            let global_id = next_dc_bus_id;
3701            next_dc_bus_id += 1;
3702            local_to_global.insert(dc_bus.dc_bus, global_id);
3703            network
3704                .hvdc
3705                .ensure_dc_grid(grid_id, Some(system.name.clone()))
3706                .buses
3707                .push(DcBus {
3708                    bus_id: global_id,
3709                    p_dc_mw: 0.0,
3710                    v_dc_pu: 1.0,
3711                    base_kv_dc,
3712                    v_dc_max: 1.1,
3713                    v_dc_min: 0.9,
3714                    cost: 0.0,
3715                    g_shunt_siemens: 0.0,
3716                    r_ground_ohm: dc_bus.rgrnd,
3717                });
3718        }
3719
3720        for converter in &system.converters {
3721            let Some(&dc_bus) = system
3722                .dc_buses
3723                .iter()
3724                .find(|dc_bus| dc_bus.ac_bus == converter.bus)
3725                .and_then(|dc_bus| local_to_global.get(&dc_bus.dc_bus))
3726            else {
3727                continue;
3728            };
3729
3730            let scheduled_setpoint = if converter.cnvcod == 2 {
3731                -converter.scheduled_setpoint.abs()
3732            } else {
3733                converter.scheduled_setpoint.abs()
3734            };
3735
3736            network
3737                .hvdc
3738                .ensure_dc_grid(grid_id, Some(system.name.clone()))
3739                .converters
3740                .push(DcConverter::Lcc(LccDcConverter {
3741                    id: String::new(),
3742                    dc_bus,
3743                    ac_bus: converter.bus,
3744                    n_bridges: converter.n_bridges,
3745                    alpha_max_deg: converter.alpha_max,
3746                    alpha_min_deg: converter.alpha_min,
3747                    gamma_min_deg: 15.0,
3748                    commutation_resistance_ohm: converter.commutation_resistance_ohm,
3749                    commutation_reactance_ohm: converter.commutation_reactance_ohm,
3750                    base_voltage_kv: converter.base_voltage_kv.max(base_kv_dc),
3751                    turns_ratio: converter.turns_ratio,
3752                    tap_ratio: converter.tap,
3753                    tap_max: converter.tap_max,
3754                    tap_min: converter.tap_min,
3755                    tap_step: converter.tap_step,
3756                    scheduled_setpoint,
3757                    power_share_percent: converter.dcpf,
3758                    current_margin_percent: converter.marg,
3759                    role: if converter.cnvcod == 2 {
3760                        LccDcConverterRole::Inverter
3761                    } else {
3762                        LccDcConverterRole::Rectifier
3763                    },
3764                    in_service: true,
3765                }));
3766        }
3767
3768        for link in &system.dc_links {
3769            let Some(&from_bus) = local_to_global.get(&link.from_dc_bus) else {
3770                continue;
3771            };
3772            let Some(&to_bus) = local_to_global.get(&link.to_dc_bus) else {
3773                continue;
3774            };
3775            let grid = network
3776                .hvdc
3777                .ensure_dc_grid(grid_id, Some(system.name.clone()));
3778            grid.branches.push(DcBranch {
3779                id: format!("dc_grid_{}_branch_{}", grid.id, grid.branches.len() + 1),
3780                from_bus,
3781                to_bus,
3782                r_ohm: link.resistance_ohm,
3783                l_mh: link.ldc,
3784                c_uf: 0.0,
3785                rating_a_mva: 0.0,
3786                rating_b_mva: 0.0,
3787                rating_c_mva: 0.0,
3788                status: true,
3789            });
3790        }
3791    }
3792}
3793
3794// ---------------------------------------------------------------------------
3795// Skip section helper - consume lines until section terminator
3796// ---------------------------------------------------------------------------
3797
3798#[allow(dead_code)]
3799fn skip_section(lines: &[&str], start: usize) -> usize {
3800    let mut pos = start;
3801    while pos < lines.len() {
3802        let line = lines[pos].trim();
3803        if is_section_end(line) {
3804            return pos + 1;
3805        }
3806        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
3807            return pos + 1;
3808        }
3809        pos += 1;
3810    }
3811    pos
3812}
3813
3814// ---------------------------------------------------------------------------
3815// System Switching Device Data (v34+, between Branch and Transformer sections)
3816// ---------------------------------------------------------------------------
3817
3818/// A system-level switching device connecting nodes across different buses.
3819struct RawSystemSwitchDevice {
3820    /// "From" bus number.
3821    bus_i: u32,
3822    /// "To" bus number.
3823    bus_j: u32,
3824    /// Circuit/device identifier.
3825    ckt: String,
3826    /// Device name.
3827    name: String,
3828    /// Device type: 1=ZBR, 2=Breaker, 3=Disconnect.
3829    device_type: u32,
3830    /// Operating status: 1=closed, 0=open.
3831    status: u32,
3832    /// Normal status: 1=normally closed, 0=normally open.
3833    normal_status: u32,
3834    /// Reactance in per-unit (stored for future use in impedance modeling).
3835    #[allow(dead_code)]
3836    x_pu: f64,
3837    /// MVA rating (first rating set).
3838    rate1: f64,
3839}
3840
3841fn psse_switch_type(device_type: u32) -> SwitchType {
3842    match device_type {
3843        1 => SwitchType::Switch, // ZBR (zero bus reactance)
3844        2 => SwitchType::Breaker,
3845        3 => SwitchType::Disconnector,
3846        _ => SwitchType::Switch,
3847    }
3848}
3849
3850// ---------------------------------------------------------------------------
3851// Voltage Droop Control Data (v36)
3852// ---------------------------------------------------------------------------
3853
3854fn parse_voltage_droop_control_section(
3855    lines: &[&str],
3856    start: usize,
3857) -> (
3858    Vec<surge_network::network::voltage_droop_control::VoltageDroopControl>,
3859    usize,
3860) {
3861    let mut controls = Vec::new();
3862    let mut pos = start;
3863
3864    while pos < lines.len() {
3865        let line = lines[pos].trim();
3866        if line.is_empty() || line.starts_with("@!") {
3867            pos += 1;
3868            continue;
3869        }
3870        if is_section_end(line) {
3871            pos += 1;
3872            break;
3873        }
3874
3875        let fields = tokenize_record(line);
3876        if fields.len() < 7 {
3877            pos += 1;
3878            continue;
3879        }
3880
3881        controls.push(
3882            surge_network::network::voltage_droop_control::VoltageDroopControl {
3883                bus: fields[0].parse().unwrap_or(0),
3884                device_id: fields[1].clone(),
3885                device_type: fields[2].parse().unwrap_or(1),
3886                regulated_bus: fields[3].parse().unwrap_or(0),
3887                vdrp: fields[4].parse().unwrap_or(0.0),
3888                vmax: fields[5].parse().unwrap_or(1.1),
3889                vmin: fields[6].parse().unwrap_or(0.9),
3890            },
3891        );
3892        pos += 1;
3893    }
3894
3895    if !controls.is_empty() {
3896        tracing::debug!(count = controls.len(), "parsed voltage droop control data");
3897    }
3898    (controls, pos)
3899}
3900
3901// ---------------------------------------------------------------------------
3902// Switching Device Rating Set Data (v36)
3903// ---------------------------------------------------------------------------
3904
3905fn parse_switching_device_rating_set_section(
3906    lines: &[&str],
3907    start: usize,
3908) -> (
3909    Vec<surge_network::network::switching_device_rating::SwitchingDeviceRatingSet>,
3910    usize,
3911) {
3912    let mut sets = Vec::new();
3913    let mut pos = start;
3914
3915    while pos < lines.len() {
3916        let line = lines[pos].trim();
3917        if line.is_empty() || line.starts_with("@!") {
3918            pos += 1;
3919            continue;
3920        }
3921        if is_section_end(line) {
3922            pos += 1;
3923            break;
3924        }
3925
3926        let fields = tokenize_record(line);
3927        if fields.len() < 7 {
3928            pos += 1;
3929            continue;
3930        }
3931
3932        let mut additional = Vec::new();
3933        for i in 7..fields.len() {
3934            if let Ok(r) = fields[i].parse::<f64>() {
3935                additional.push(r);
3936            }
3937        }
3938
3939        sets.push(
3940            surge_network::network::switching_device_rating::SwitchingDeviceRatingSet {
3941                from_bus: fields[0].parse().unwrap_or(0),
3942                to_bus: fields[1].parse().unwrap_or(0),
3943                circuit: fields[2].clone(),
3944                rating_set: fields[3].parse().unwrap_or(1),
3945                rate1: fields[4].parse().unwrap_or(0.0),
3946                rate2: fields[5].parse().unwrap_or(0.0),
3947                rate3: fields[6].parse().unwrap_or(0.0),
3948                additional_rates: additional,
3949            },
3950        );
3951        pos += 1;
3952    }
3953
3954    if !sets.is_empty() {
3955        tracing::debug!(
3956            count = sets.len(),
3957            "parsed switching device rating set data"
3958        );
3959    }
3960    (sets, pos)
3961}
3962
3963/// Parse the SYSTEM SWITCHING DEVICE DATA section (v34+).
3964///
3965/// This section sits between Branch Data and Transformer Data.  Records have
3966/// the format: NI, NJ, CKT, NAME, TYPE, STATUS, NSTAT, X, RATE1, RATE2, RATE3
3967///
3968/// Returns (devices, next_pos).  If the section is empty or missing, returns
3969/// an empty vec and the same position.
3970fn parse_system_switching_device_section(
3971    lines: &[&str],
3972    start: usize,
3973) -> (Vec<RawSystemSwitchDevice>, usize) {
3974    let mut devices = Vec::new();
3975    let mut pos = start;
3976
3977    while pos < lines.len() {
3978        let line = lines[pos].trim();
3979        if line.is_empty() || line.starts_with("@!") {
3980            pos += 1;
3981            continue;
3982        }
3983        if is_section_end(line) {
3984            pos += 1;
3985            break;
3986        }
3987
3988        let fields = tokenize_record(line);
3989        if fields.len() < 6 {
3990            pos += 1;
3991            continue;
3992        }
3993
3994        let bus_i = fields[0].parse::<u32>().unwrap_or(0);
3995        let bus_j = fields[1].parse::<u32>().unwrap_or(0);
3996        let ckt = fields.get(2).cloned().unwrap_or_else(|| "1".into());
3997        let name = fields.get(3).cloned().unwrap_or_default();
3998        let device_type = fields.get(4).and_then(|s| s.parse().ok()).unwrap_or(1);
3999        let status = fields.get(5).and_then(|s| s.parse().ok()).unwrap_or(1);
4000        let normal_status = fields.get(6).and_then(|s| s.parse().ok()).unwrap_or(1);
4001        let x_pu = fields.get(7).and_then(|s| s.parse().ok()).unwrap_or(0.0001);
4002        let rate1 = fields.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0);
4003
4004        devices.push(RawSystemSwitchDevice {
4005            bus_i,
4006            bus_j,
4007            ckt,
4008            name,
4009            device_type,
4010            status,
4011            normal_status,
4012            x_pu,
4013            rate1,
4014        });
4015        pos += 1;
4016    }
4017
4018    if !devices.is_empty() {
4019        tracing::debug!(count = devices.len(), "parsed system switching device data");
4020    }
4021    (devices, pos)
4022}
4023
4024// ---------------------------------------------------------------------------
4025// Substation Data (v35+)
4026// ---------------------------------------------------------------------------
4027
4028// ---------------------------------------------------------------------------
4029// Induction Machine Data (v35+)
4030// ---------------------------------------------------------------------------
4031
4032/// Parse the INDUCTION MACHINE DATA section (v35+).
4033///
4034/// Each two-line record (continuation with `/`) describes one induction motor:
4035///
4036/// Line 1: `I, 'ID', STAT, SCODE, DCODE, AREA, ZONE, OWNER, TCODE, BCODE,
4037///           MBASE, RATEKV, PCODE, PSET, H, A, B, D, E, F`
4038/// Line 2: `RA, XA, XM, R1, X1, R2, X2, X3, E1, SE1, E2, SE2, IA1, IA2, XAMULT`
4039///
4040/// Tokens beyond what Surge currently uses are silently ignored.
4041fn parse_induction_machine_section(
4042    lines: &[&str],
4043    start: usize,
4044) -> Vec<surge_network::network::induction_machine::InductionMachine> {
4045    use surge_network::network::induction_machine::InductionMachine;
4046    let mut machines = Vec::new();
4047    // `start` already points to the first data line (seek_section consumed the marker).
4048    let mut pos = start;
4049
4050    while pos < lines.len() {
4051        let l = lines[pos].trim();
4052        if l.is_empty() || l.starts_with("@!") {
4053            pos += 1;
4054            continue;
4055        }
4056        if is_section_end(l) {
4057            break;
4058        }
4059
4060        let f1 = tokenize_record(l);
4061        pos += 1;
4062        if f1.len() < 4 {
4063            continue;
4064        }
4065
4066        // Line 2 (circuit parameters) — optional, may be absent in malformed input.
4067        let f2 = if pos < lines.len() {
4068            let l2 = lines[pos].trim();
4069            if !l2.is_empty() && !is_section_end(l2) && !l2.starts_with("@!") {
4070                pos += 1;
4071                tokenize_record(l2)
4072            } else {
4073                Vec::new()
4074            }
4075        } else {
4076            Vec::new()
4077        };
4078
4079        let bus: u32 = f1[0].parse().unwrap_or(0);
4080        let id = f1
4081            .get(1)
4082            .map(|s| s.trim().trim_matches('\'').to_string())
4083            .unwrap_or_else(|| "1".to_string());
4084        let stat: i32 = f1.get(2).and_then(|t| t.parse().ok()).unwrap_or(1);
4085        // skip SCODE(3), DCODE(4)
4086        let area: u32 = f1.get(5).and_then(|t| t.parse().ok()).unwrap_or(1);
4087        let zone: u32 = f1.get(6).and_then(|t| t.parse().ok()).unwrap_or(1);
4088        let owner: u32 = f1.get(7).and_then(|t| t.parse().ok()).unwrap_or(1);
4089        // skip TCODE(8), BCODE(9)
4090        let pf = |s: &str| s.trim().parse::<f64>().unwrap_or(0.0);
4091        let mbase: f64 = f1.get(10).map(|t| pf(t)).unwrap_or(0.0);
4092        let rate_kv: f64 = f1.get(11).map(|t| pf(t)).unwrap_or(0.0);
4093        // skip PCODE(12)
4094        let pset: f64 = f1.get(13).map(|t| pf(t)).unwrap_or(0.0);
4095        let h: f64 = f1.get(14).map(|t| pf(t)).unwrap_or(0.0);
4096        let a: f64 = f1.get(15).map(|t| pf(t)).unwrap_or(0.0);
4097        let b: f64 = f1.get(16).map(|t| pf(t)).unwrap_or(0.0);
4098        let d: f64 = f1.get(17).map(|t| pf(t)).unwrap_or(0.0);
4099        let e: f64 = f1.get(18).map(|t| pf(t)).unwrap_or(0.0);
4100        let f_coeff: f64 = f1.get(19).map(|t| pf(t)).unwrap_or(0.0);
4101
4102        let ra: f64 = f2.first().map(|t| pf(t)).unwrap_or(0.0);
4103        let xa: f64 = f2.get(1).map(|t| pf(t)).unwrap_or(0.0);
4104        let xm: f64 = f2.get(2).map(|t| pf(t)).unwrap_or(0.0);
4105        let r1: f64 = f2.get(3).map(|t| pf(t)).unwrap_or(0.0);
4106        let x1: f64 = f2.get(4).map(|t| pf(t)).unwrap_or(0.0);
4107        let r2: f64 = f2.get(5).map(|t| pf(t)).unwrap_or(0.0);
4108        let x2: f64 = f2.get(6).map(|t| pf(t)).unwrap_or(0.0);
4109        let x3: f64 = f2.get(7).map(|t| pf(t)).unwrap_or(0.0);
4110
4111        machines.push(InductionMachine {
4112            bus,
4113            id,
4114            in_service: stat != 0,
4115            mbase,
4116            rate_kv,
4117            pset,
4118            h,
4119            a,
4120            b,
4121            d,
4122            e,
4123            f_coeff,
4124            ra,
4125            xa,
4126            xm,
4127            r1,
4128            x1,
4129            r2,
4130            x2,
4131            x3,
4132            area,
4133            zone,
4134            owner,
4135            load_id: None,
4136        });
4137    }
4138
4139    machines
4140}
4141
4142/// ```text
4143/// ISUB, 'NAME', LATI, LONG, SGR
4144///   / BEGIN SUBSTATION NODE DATA
4145///     INODE, 'NAME', IBUS, STATUS, VM, VA
4146///   0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA
4147///     NI, NJ, 'CKT', 'NAME', TYPE, STATUS, NSTAT, X, RATE1, RATE2, RATE3
4148///   0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA
4149///     ISUB, INODE, TYPE, ...
4150///   0 / END OF SUBSTATION TERMINAL DATA
4151/// ```
4152fn parse_substation_data_section(
4153    lines: &[&str],
4154    start: usize,
4155    bus_basekv: &HashMap<u32, f64>,
4156    sys_switch_devices: &[RawSystemSwitchDevice],
4157) -> NodeBreakerTopology {
4158    let mut substations = Vec::new();
4159    let mut voltage_levels = Vec::new();
4160    let mut connectivity_nodes = Vec::new();
4161    let mut busbar_sections = Vec::new();
4162    let mut switches = Vec::new();
4163    let mut terminal_connections = Vec::new();
4164    let bays = Vec::new();
4165
4166    let mut pos = start;
4167
4168    // Track unique (substation_id, base_kv) pairs for voltage levels.
4169    let mut vl_set: std::collections::HashSet<(String, u64)> = std::collections::HashSet::new();
4170
4171    // Accumulate node→bus associations across all substations for topology reduction.
4172    let mut all_connectivity_node_to_bus: HashMap<String, u32> = HashMap::new();
4173
4174    while pos < lines.len() {
4175        let line = lines[pos].trim();
4176        if line.is_empty() || line.starts_with("@!") {
4177            pos += 1;
4178            continue;
4179        }
4180        if is_section_end(line) {
4181            // End of entire SUBSTATION DATA section.
4182            pos += 1;
4183            break;
4184        }
4185        if line.starts_with('Q') || line.eq_ignore_ascii_case("q") {
4186            break;
4187        }
4188
4189        // Parse substation header: ISUB, 'NAME', LATI, LONG, SGR
4190        let fields = tokenize_record(line);
4191        if fields.is_empty() {
4192            pos += 1;
4193            continue;
4194        }
4195
4196        let isub: u32 = fields[0].parse().unwrap_or(0);
4197        let sub_name = fields.get(1).cloned().unwrap_or_default();
4198        let sub_id = format!("SUB_{isub}");
4199
4200        substations.push(SubstationData {
4201            id: sub_id.clone(),
4202            name: sub_name,
4203            region: None,
4204        });
4205
4206        pos += 1;
4207
4208        // Skip "/ BEGIN SUBSTATION NODE DATA" marker line.
4209        while pos < lines.len() {
4210            let l = lines[pos].trim();
4211            if l.is_empty() || l.starts_with("@!") || l.starts_with('/') {
4212                pos += 1;
4213                continue;
4214            }
4215            break;
4216        }
4217
4218        // Parse node records until section end.
4219        // Node fields: INODE, 'NAME', IBUS, STATUS, VM, VA
4220        let mut sub_nodes: Vec<(u32, String, u32)> = Vec::new(); // (inode, name, ibus)
4221
4222        while pos < lines.len() {
4223            let l = lines[pos].trim();
4224            if l.is_empty() || l.starts_with("@!") {
4225                pos += 1;
4226                continue;
4227            }
4228            if is_section_end(l) {
4229                pos += 1;
4230                break;
4231            }
4232            let nf = tokenize_record(l);
4233            if nf.len() >= 3 {
4234                let inode: u32 = nf[0].parse().unwrap_or(0);
4235                let node_name = nf.get(1).cloned().unwrap_or_default();
4236                let ibus: u32 = nf[2].parse().unwrap_or(0);
4237
4238                let cn_id = format!("SUB_{isub}_N{inode}");
4239                let base_kv = bus_basekv.get(&ibus).copied().unwrap_or(1.0);
4240                let vl_id = format!("VL_{sub_id}_{}", (base_kv * 10.0) as u64);
4241
4242                // Create VoltageLevel if not yet seen.
4243                let vl_key = (sub_id.clone(), (base_kv * 10.0) as u64);
4244                if vl_set.insert(vl_key) {
4245                    voltage_levels.push(VoltageLevel {
4246                        id: vl_id.clone(),
4247                        name: format!("{base_kv} kV"),
4248                        substation_id: sub_id.clone(),
4249                        base_kv,
4250                    });
4251                }
4252
4253                connectivity_nodes.push(ConnectivityNode {
4254                    id: cn_id.clone(),
4255                    name: node_name.clone(),
4256                    voltage_level_id: vl_id,
4257                });
4258
4259                // Record CN→bus mapping for topology.
4260                all_connectivity_node_to_bus.insert(cn_id.clone(), ibus);
4261
4262                // If the node name contains "NB" (busbar), create a BusbarSection.
4263                if node_name.contains("NB") || node_name.contains("BB") {
4264                    busbar_sections.push(BusbarSection {
4265                        id: format!("BB_{cn_id}"),
4266                        name: node_name.clone(),
4267                        connectivity_node_id: cn_id.clone(),
4268                        ip_max: None,
4269                    });
4270                }
4271
4272                sub_nodes.push((inode, cn_id, ibus));
4273            }
4274            pos += 1;
4275        }
4276
4277        // Build node-id lookup for this substation.
4278        let node_cn: HashMap<u32, &str> = sub_nodes
4279            .iter()
4280            .map(|(inode, cn_id, _)| (*inode, cn_id.as_str()))
4281            .collect();
4282
4283        // Parse switching device records until section end.
4284        // Fields: NI, NJ, 'CKT', 'NAME', TYPE, STATUS, NSTAT, X, RATE1, RATE2, RATE3
4285        while pos < lines.len() {
4286            let l = lines[pos].trim();
4287            if l.is_empty() || l.starts_with("@!") {
4288                pos += 1;
4289                continue;
4290            }
4291            if is_section_end(l) {
4292                pos += 1;
4293                break;
4294            }
4295            let sf = tokenize_record(l);
4296            if sf.len() >= 6 {
4297                let ni: u32 = sf[0].parse().unwrap_or(0);
4298                let nj: u32 = sf[1].parse().unwrap_or(0);
4299                let ckt = sf.get(2).cloned().unwrap_or_else(|| "1".into());
4300                let sw_name = sf.get(3).cloned().unwrap_or_default();
4301                let device_type: u32 = sf.get(4).and_then(|s| s.parse().ok()).unwrap_or(1);
4302                let status: u32 = sf.get(5).and_then(|s| s.parse().ok()).unwrap_or(1);
4303                let normal_status: u32 = sf.get(6).and_then(|s| s.parse().ok()).unwrap_or(1);
4304                let rate1: f64 = sf.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0);
4305
4306                let cn1 = node_cn.get(&ni).unwrap_or(&"").to_string();
4307                let cn2 = node_cn.get(&nj).unwrap_or(&"").to_string();
4308                if !cn1.is_empty() && !cn2.is_empty() {
4309                    let sw_id = format!("SW_{sub_id}_N{ni}_N{nj}_{ckt}");
4310                    let rated_current = if rate1 > 0.0 { Some(rate1) } else { None };
4311
4312                    switches.push(SwitchDevice {
4313                        id: sw_id,
4314                        name: sw_name,
4315                        switch_type: psse_switch_type(device_type),
4316                        cn1_id: cn1,
4317                        cn2_id: cn2,
4318                        open: status == 0,
4319                        normal_open: normal_status == 0,
4320                        retained: false,
4321                        rated_current,
4322                    });
4323                }
4324            }
4325            pos += 1;
4326        }
4327
4328        // Parse terminal records until section end.
4329        // Terminal formats vary by type:
4330        //   ISUB, INODE, 'M', 'CKT'              — machine (generator)
4331        //   ISUB, INODE, 'L', 'CKT'              — load
4332        //   ISUB, INODE, 'B', JBUS, 'CKT'        — branch/transformer to bus JBUS
4333        //   ISUB, INODE, 'X', JBUS, 'CKT'        — (same as B for 2W xfmr)
4334        //   ISUB, INODE, 'S', 'CKT'              — switched shunt
4335        //   ISUB, INODE, 'F', 'CKT'              — fixed shunt
4336        while pos < lines.len() {
4337            let l = lines[pos].trim();
4338            if l.is_empty() || l.starts_with("@!") {
4339                pos += 1;
4340                continue;
4341            }
4342            if is_section_end(l) {
4343                pos += 1;
4344                break;
4345            }
4346            let tf = tokenize_record(l);
4347            if tf.len() >= 3 {
4348                let _term_sub: u32 = tf[0].parse().unwrap_or(0);
4349                let term_node: u32 = tf[1].parse().unwrap_or(0);
4350                let term_type = tf.get(2).cloned().unwrap_or_default();
4351
4352                let cn_id = node_cn.get(&term_node).unwrap_or(&"").to_string();
4353                if cn_id.is_empty() {
4354                    pos += 1;
4355                    continue;
4356                }
4357
4358                let (equip_id, equip_class, seq) = match term_type.as_str() {
4359                    "M" => {
4360                        let ckt = tf.get(3).cloned().unwrap_or_else(|| "1".into());
4361                        // Find the bus for this node.
4362                        let ibus = sub_nodes
4363                            .iter()
4364                            .find(|(n, _, _)| *n == term_node)
4365                            .map(|(_, _, b)| *b)
4366                            .unwrap_or(0);
4367                        (
4368                            format!("GEN_{ibus}_{ckt}"),
4369                            "SynchronousMachine".into(),
4370                            1u32,
4371                        )
4372                    }
4373                    "L" => {
4374                        let ckt = tf.get(3).cloned().unwrap_or_else(|| "1".into());
4375                        let ibus = sub_nodes
4376                            .iter()
4377                            .find(|(n, _, _)| *n == term_node)
4378                            .map(|(_, _, b)| *b)
4379                            .unwrap_or(0);
4380                        (format!("LOAD_{ibus}_{ckt}"), "EnergyConsumer".into(), 1)
4381                    }
4382                    "B" | "X" => {
4383                        let jbus: u32 = tf.get(3).and_then(|s| s.parse().ok()).unwrap_or(0);
4384                        let ckt = tf.get(4).cloned().unwrap_or_else(|| "1".into());
4385                        let ibus = sub_nodes
4386                            .iter()
4387                            .find(|(n, _, _)| *n == term_node)
4388                            .map(|(_, _, b)| *b)
4389                            .unwrap_or(0);
4390                        let class = if term_type == "X" {
4391                            "PowerTransformer"
4392                        } else {
4393                            "ACLineSegment"
4394                        };
4395                        (format!("BR_{ibus}_{jbus}_{ckt}"), class.into(), 1)
4396                    }
4397                    _ => {
4398                        // Fixed shunt, switched shunt, etc. — still record the terminal.
4399                        let ckt = tf.get(3).cloned().unwrap_or_else(|| "1".into());
4400                        let ibus = sub_nodes
4401                            .iter()
4402                            .find(|(n, _, _)| *n == term_node)
4403                            .map(|(_, _, b)| *b)
4404                            .unwrap_or(0);
4405                        (
4406                            format!("EQUIP_{ibus}_{term_type}_{ckt}"),
4407                            term_type.clone(),
4408                            1,
4409                        )
4410                    }
4411                };
4412
4413                let term_id = format!("T_{cn_id}_{equip_id}");
4414                terminal_connections.push(TerminalConnection {
4415                    terminal_id: term_id,
4416                    equipment_id: equip_id,
4417                    equipment_class: equip_class,
4418                    sequence_number: seq,
4419                    connectivity_node_id: cn_id,
4420                });
4421            }
4422            pos += 1;
4423        }
4424    }
4425    let _ = pos; // suppress unused-assignment warning
4426
4427    // Incorporate system-level switching devices (inter-bus).
4428    // These connect buses rather than substation-local nodes, so we create
4429    // synthetic CNs for each bus endpoint.
4430    for ssd in sys_switch_devices {
4431        let cn1_id = format!("SYSBUS_{}", ssd.bus_i);
4432        let cn2_id = format!("SYSBUS_{}", ssd.bus_j);
4433        let sw_id = format!("SYS_SW_{}_{}_{}", ssd.bus_i, ssd.bus_j, ssd.ckt);
4434
4435        // Ensure CNs exist (may have been created by a prior sys device).
4436        if !connectivity_nodes.iter().any(|cn| cn.id == cn1_id) {
4437            let base_kv = bus_basekv.get(&ssd.bus_i).copied().unwrap_or(1.0);
4438            connectivity_nodes.push(ConnectivityNode {
4439                id: cn1_id.clone(),
4440                name: format!("Bus_{}", ssd.bus_i),
4441                voltage_level_id: format!("VL_SYS_{}", (base_kv * 10.0) as u64),
4442            });
4443        }
4444        if !connectivity_nodes.iter().any(|cn| cn.id == cn2_id) {
4445            let base_kv = bus_basekv.get(&ssd.bus_j).copied().unwrap_or(1.0);
4446            connectivity_nodes.push(ConnectivityNode {
4447                id: cn2_id.clone(),
4448                name: format!("Bus_{}", ssd.bus_j),
4449                voltage_level_id: format!("VL_SYS_{}", (base_kv * 10.0) as u64),
4450            });
4451        }
4452
4453        let rated_current = if ssd.rate1 > 0.0 {
4454            Some(ssd.rate1)
4455        } else {
4456            None
4457        };
4458        switches.push(SwitchDevice {
4459            id: sw_id,
4460            name: ssd.name.clone(),
4461            switch_type: psse_switch_type(ssd.device_type),
4462            cn1_id,
4463            cn2_id,
4464            open: ssd.status == 0,
4465            normal_open: ssd.normal_status == 0,
4466            retained: false,
4467            rated_current,
4468        });
4469    }
4470
4471    // Build topology reduction from the accumulated node→bus associations.
4472    let mut bus_to_connectivity_nodes: HashMap<u32, Vec<String>> = HashMap::new();
4473    for (cn_id, &bus) in &all_connectivity_node_to_bus {
4474        bus_to_connectivity_nodes
4475            .entry(bus)
4476            .or_default()
4477            .push(cn_id.clone());
4478    }
4479
4480    // Consumed switches: closed switches whose two CNs map to the same bus.
4481    let consumed_switch_ids: Vec<String> = switches
4482        .iter()
4483        .filter(|sw| !sw.open && !sw.retained)
4484        .filter(|sw| {
4485            all_connectivity_node_to_bus.get(&sw.cn1_id)
4486                == all_connectivity_node_to_bus.get(&sw.cn2_id)
4487                && all_connectivity_node_to_bus.contains_key(&sw.cn1_id)
4488        })
4489        .map(|sw| sw.id.clone())
4490        .collect();
4491
4492    // Isolated CNs: no terminal connections.
4493    let connected_cns: std::collections::HashSet<&str> = terminal_connections
4494        .iter()
4495        .map(|tc| tc.connectivity_node_id.as_str())
4496        .collect();
4497    let isolated_connectivity_node_ids: Vec<String> = connectivity_nodes
4498        .iter()
4499        .filter(|cn| !connected_cns.contains(cn.id.as_str()))
4500        .map(|cn| cn.id.clone())
4501        .collect();
4502
4503    let reduction = if all_connectivity_node_to_bus.is_empty() {
4504        None
4505    } else {
4506        Some(TopologyMapping {
4507            connectivity_node_to_bus: all_connectivity_node_to_bus,
4508            bus_to_connectivity_nodes,
4509            consumed_switch_ids,
4510            isolated_connectivity_node_ids,
4511        })
4512    };
4513
4514    tracing::debug!(
4515        substations = substations.len(),
4516        nodes = connectivity_nodes.len(),
4517        switches = switches.len(),
4518        terminals = terminal_connections.len(),
4519        "parsed PSS/E substation data"
4520    );
4521
4522    match reduction {
4523        Some(reduction) => NodeBreakerTopology::new(
4524            substations,
4525            voltage_levels,
4526            bays,
4527            connectivity_nodes,
4528            busbar_sections,
4529            switches,
4530            terminal_connections,
4531        )
4532        .with_mapping(reduction),
4533        None => NodeBreakerTopology::new(
4534            substations,
4535            voltage_levels,
4536            bays,
4537            connectivity_nodes,
4538            busbar_sections,
4539            switches,
4540            terminal_connections,
4541        ),
4542    }
4543}
4544
4545/// Build a minimal NodeBreakerTopology from system-level switching devices only
4546/// (when no SUBSTATION DATA section is present).
4547fn build_sys_switch_model(
4548    sys_devices: &[RawSystemSwitchDevice],
4549    _network: &Network,
4550    bus_basekv: &HashMap<u32, f64>,
4551) -> NodeBreakerTopology {
4552    let mut connectivity_nodes = Vec::new();
4553    let mut switches = Vec::new();
4554    let mut voltage_levels = Vec::new();
4555    let mut vl_set: std::collections::HashSet<u64> = std::collections::HashSet::new();
4556    let mut cn_set: std::collections::HashSet<u32> = std::collections::HashSet::new();
4557
4558    for ssd in sys_devices {
4559        for &bus in &[ssd.bus_i, ssd.bus_j] {
4560            if cn_set.insert(bus) {
4561                let base_kv = bus_basekv.get(&bus).copied().unwrap_or(1.0);
4562                let vl_key = (base_kv * 10.0) as u64;
4563                if vl_set.insert(vl_key) {
4564                    voltage_levels.push(VoltageLevel {
4565                        id: format!("VL_SYS_{vl_key}"),
4566                        name: format!("{base_kv} kV"),
4567                        substation_id: "SYS".into(),
4568                        base_kv,
4569                    });
4570                }
4571                connectivity_nodes.push(ConnectivityNode {
4572                    id: format!("SYSBUS_{bus}"),
4573                    name: format!("Bus_{bus}"),
4574                    voltage_level_id: format!("VL_SYS_{vl_key}"),
4575                });
4576            }
4577        }
4578
4579        let sw_id = format!("SYS_SW_{}_{}_{}", ssd.bus_i, ssd.bus_j, ssd.ckt);
4580        let rated_current = if ssd.rate1 > 0.0 {
4581            Some(ssd.rate1)
4582        } else {
4583            None
4584        };
4585        switches.push(SwitchDevice {
4586            id: sw_id,
4587            name: ssd.name.clone(),
4588            switch_type: psse_switch_type(ssd.device_type),
4589            cn1_id: format!("SYSBUS_{}", ssd.bus_i),
4590            cn2_id: format!("SYSBUS_{}", ssd.bus_j),
4591            open: ssd.status == 0,
4592            normal_open: ssd.normal_status == 0,
4593            retained: false,
4594            rated_current,
4595        });
4596    }
4597
4598    NodeBreakerTopology::new(
4599        vec![SubstationData {
4600            id: "SYS".into(),
4601            name: "System".into(),
4602            region: None,
4603        }],
4604        voltage_levels,
4605        Vec::new(),
4606        connectivity_nodes,
4607        Vec::new(),
4608        switches,
4609        Vec::new(),
4610    )
4611}
4612
4613#[cfg(test)]
4614mod tests {
4615    use super::*;
4616    #[allow(dead_code)]
4617    fn data_available() -> bool {
4618        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
4619            return std::path::Path::new(&p).exists();
4620        }
4621        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4622            .parent()
4623            .unwrap()
4624            .parent()
4625            .unwrap()
4626            .join("tests/data")
4627            .exists()
4628    }
4629    #[allow(dead_code)]
4630    fn test_data_dir() -> std::path::PathBuf {
4631        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
4632            return std::path::PathBuf::from(p);
4633        }
4634        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4635            .parent()
4636            .unwrap()
4637            .parent()
4638            .unwrap()
4639            .join("tests/data")
4640    }
4641
4642    #[test]
4643    fn test_tokenize_comma_delimited() {
4644        let tokens = tokenize_record("1, 'BUS 1   ', 138.0, 1, 1, 1, 1, 1.050, -10.5");
4645        assert_eq!(tokens[0], "1");
4646        assert_eq!(tokens[1], "'BUS 1   '");
4647        assert_eq!(tokens[2], "138.0");
4648        assert_eq!(tokens[3], "1");
4649        assert_eq!(tokens.len(), 9);
4650    }
4651
4652    #[test]
4653    fn test_tokenize_with_comment() {
4654        let tokens = tokenize_record("1, 2, 3 / this is a comment");
4655        assert_eq!(tokens.len(), 3);
4656        assert_eq!(tokens[2], "3");
4657    }
4658
4659    #[test]
4660    fn test_section_end() {
4661        assert!(is_section_end("0 / END OF BUS DATA"));
4662        assert!(is_section_end("0"));
4663        assert!(is_section_end(" 0 / END OF DATA"));
4664        assert!(!is_section_end("10, 'BUS', 138.0"));
4665        // PE-01 regression: "0," must NOT be treated as a section terminator
4666        assert!(!is_section_end("0,5,'1 ',0.01,0.1,0.0,100.0"));
4667        assert!(!is_section_end("0, 5, '1 '"));
4668        // Other patterns that must not match
4669        assert!(!is_section_end("01"));
4670        assert!(!is_section_end("0.0"));
4671        assert!(!is_section_end("0.01, 0.1"));
4672    }
4673
4674    #[test]
4675    fn test_parse_f64_rejects_nan_inf() {
4676        // PE-04: parse_f64 must reject NaN and Inf strings
4677        assert!(parse_f64("NaN", 1, "test").is_err());
4678        assert!(parse_f64("nan", 1, "test").is_err());
4679        assert!(parse_f64("inf", 1, "test").is_err());
4680        assert!(parse_f64("Inf", 1, "test").is_err());
4681        assert!(parse_f64("-inf", 1, "test").is_err());
4682        assert!(parse_f64("-Inf", 1, "test").is_err());
4683        // Zero and normal values must still be accepted (0.0 = unconstrained rating)
4684        assert_eq!(parse_f64("0.0", 1, "rate").unwrap(), 0.0);
4685        assert_eq!(parse_f64("0", 1, "rate").unwrap(), 0.0);
4686        assert!((parse_f64("1.5", 1, "r").unwrap() - 1.5).abs() < 1e-12);
4687        assert!((parse_f64("-3.15", 1, "x").unwrap() + 3.15).abs() < 1e-12);
4688    }
4689
4690    #[test]
4691    fn test_parse_switched_shunt_rejects_truncated_record() {
4692        let lines = vec!["1, 1", "Q"];
4693        let err = parse_switched_shunt_section(&lines, 0).unwrap_err();
4694        match err {
4695            PsseError::Parse { line, message } => {
4696                assert_eq!(line, 1);
4697                assert!(message.contains("switched shunt record is truncated"));
4698            }
4699            other => panic!("expected parse error for truncated switched shunt, got {other:?}"),
4700        }
4701    }
4702
4703    #[test]
4704    fn test_section_end_bus0_branch_not_truncated() {
4705        // PE-01 regression: a branch from bus 0 should not trigger early section
4706        // termination because "0," looks like a section end to the old code.
4707        let raw = r#"0,   100.00, 33, 0, 0, 60.00
4708Test Case
4709Test Case
4710    0,'BUS0    ',  138.0000,3,   1,   1,   1, 1.06000,   0.0000
4711    5,'BUS5    ',  138.0000,1,   1,   1,   1, 1.00000,   0.0000
47120 / END OF BUS DATA
47130 / END OF LOAD DATA
47140 / END OF FIXED SHUNT DATA
4715    5,'1 ',  100.000,    0.000,  300.000, -300.000, 1.06000,    0,  100.000,  0.0,  1.0,  0.0,  0.0,  1.0,1, 100.0,  250.000,    10.000
47160 / END OF GENERATOR DATA
4717    0,     5,'1 ', 0.01000, 0.10000, 0.02000,  100.00,  100.00,  100.00,  0.00000,  0.00000,  0.00000,  0.00000,1,1,   0.000,0,0,0,0,0
47180 / END OF NON-TRANSFORMER BRANCH DATA
47190 / END OF TRANSFORMER DATA
4720Q
4721"#;
4722        let net =
4723            parse_str(raw).expect("bus-0 branch should parse without early section termination");
4724        assert_eq!(net.n_branches(), 1, "branch from bus-0 must not be dropped");
4725        assert_eq!(net.branches[0].from_bus, 0);
4726        assert_eq!(net.branches[0].to_bus, 5);
4727    }
4728
4729    #[test]
4730    fn test_parse_truncated_transformer_errors() {
4731        let raw = r#"0,   100.00, 33, 0, 0, 60.00
4732Test Case
4733Test Case Heading 2
4734    1,'BUS1    ',  138.0000,3,   1,   1,   1, 1.06000,   0.0000
4735    2,'BUS2    ',  138.0000,1,   1,   1,   1, 1.00000,   0.0000
47360 / END OF BUS DATA
47370 / END OF LOAD DATA
47380 / END OF FIXED SHUNT DATA
4739    1,'1 ',  100.000,    0.000,  300.000, -300.000, 1.06000,    0,  100.000,  0.0,  1.0,  0.0,  0.0,  1.0,1, 100.0,  250.000,    10.000
47400 / END OF GENERATOR DATA
4741    1,     2,'1 ', 0.01000, 0.10000, 0.02000,  100.00,  100.00,  100.00,  0.00000,  0.00000,  0.00000,  0.00000,1,1,   0.000,0,0,0,0,0
47420 / END OF NON-TRANSFORMER BRANCH DATA
4743    1,     2,'1 '
47440 / END OF TRANSFORMER DATA
4745Q
4746"#;
4747        let err = parse_str(raw).unwrap_err();
4748        match err {
4749            PsseError::Parse { message, .. } => {
4750                assert!(message.contains("truncated transformer record 1"));
4751            }
4752            other => panic!("expected transformer truncation error, got {other:?}"),
4753        }
4754    }
4755
4756    #[test]
4757    fn test_parse_area_schedule_section_rejects_invalid_numeric_fields() {
4758        let lines = vec!["1, BAD, 10.0, 5.0, 'AREA 1'", "Q"];
4759        let err = parse_area_schedule_section(&lines, 0).unwrap_err();
4760        match err {
4761            PsseError::Parse { line, message } => {
4762                assert_eq!(line, 1);
4763                assert!(message.contains("ISW"));
4764            }
4765            other => panic!("expected parse error for invalid area schedule row, got {other:?}"),
4766        }
4767    }
4768
4769    #[test]
4770    fn test_parse_area_schedule_section_rejects_truncated_rows() {
4771        let lines = vec!["1, 2, 10.0, 5.0", "Q"];
4772        let err = parse_area_schedule_section(&lines, 0).unwrap_err();
4773        match err {
4774            PsseError::Parse { line, message } => {
4775                assert_eq!(line, 1);
4776                assert!(message.contains("truncated"));
4777            }
4778            other => panic!("expected parse error for truncated area schedule row, got {other:?}"),
4779        }
4780    }
4781
4782    #[test]
4783    fn test_parse_minimal_raw() {
4784        // Minimal PSS/E RAW v33 file with 2 buses, 1 generator, 1 branch
4785        let raw = r#"0,   100.00, 33, 0, 0, 60.00 / PSS/E 33 case
4786Test Case
4787Test Case Heading 2
4788    1,'BUS1    ',  138.0000,3,   1,   1,   1, 1.06000,   0.0000
4789    2,'BUS2    ',  138.0000,1,   1,   1,   1, 1.00000,   0.0000
47900 / END OF BUS DATA
4791    2,'1 ',1,   1,   1,   50.000,    20.000,    0.000,    0.000,    0.000,    0.000,   1,1,0,1,0,0
47920 / END OF LOAD DATA
47930 / END OF FIXED SHUNT DATA
4794    1,'1 ',  100.000,    0.000,  300.000, -300.000, 1.06000,    0,  100.000,  0.0,  1.0,  0.0,  0.0,  1.0,1, 100.0,  250.000,    10.000
47950 / END OF GENERATOR DATA
4796    1,     2,'1 ', 0.01000, 0.10000, 0.02000,  100.00,  100.00,  100.00,  0.00000,  0.00000,  0.00000,  0.00000,1,1,   0.000,0,0,0,0,0
47970 / END OF NON-TRANSFORMER BRANCH DATA
47980 / END OF TRANSFORMER DATA
4799Q
4800"#;
4801        let net = parse_str(raw).expect("failed to parse minimal PSS/E RAW");
4802
4803        assert_eq!(net.base_mva, 100.0);
4804        assert_eq!(net.n_buses(), 2);
4805        assert_eq!(net.generators.len(), 1);
4806        assert_eq!(net.n_branches(), 1);
4807
4808        // Check bus data
4809        assert_eq!(net.buses[0].number, 1);
4810        assert_eq!(net.buses[0].bus_type, BusType::Slack);
4811        assert!((net.buses[0].voltage_magnitude_pu - 1.06).abs() < 1e-10);
4812        assert!((net.buses[0].base_kv - 138.0).abs() < 1e-10);
4813
4814        assert_eq!(net.buses[1].number, 2);
4815        assert_eq!(net.buses[1].bus_type, BusType::PQ);
4816
4817        // Check load was applied to bus 2 (via Load objects)
4818        let bus_pd = net.bus_load_p_mw();
4819        let bus_qd = net.bus_load_q_mvar();
4820        assert!((bus_pd[1] - 50.0).abs() < 1e-10);
4821        assert!((bus_qd[1] - 20.0).abs() < 1e-10);
4822
4823        // Check generator
4824        assert_eq!(net.generators[0].bus, 1);
4825        assert!((net.generators[0].p - 100.0).abs() < 1e-10);
4826        assert!((net.generators[0].voltage_setpoint_pu - 1.06).abs() < 1e-10);
4827        assert!(net.generators[0].in_service);
4828
4829        // Check branch
4830        assert_eq!(net.branches[0].from_bus, 1);
4831        assert_eq!(net.branches[0].to_bus, 2);
4832        assert!((net.branches[0].r - 0.01).abs() < 1e-10);
4833        assert!((net.branches[0].x - 0.10).abs() < 1e-10);
4834        assert!((net.branches[0].b - 0.02).abs() < 1e-10);
4835        assert!((net.branches[0].tap - 1.0).abs() < 1e-10);
4836        assert!(net.branches[0].in_service);
4837    }
4838
4839    #[test]
4840    fn test_parse_with_transformer() {
4841        let raw = r#"0,   100.00, 33, 0, 0, 60.00
4842Test Case
4843Test Case
4844    1,'BUS1    ',  345.0000,3,   1,   1,   1, 1.06000,   0.0000
4845    2,'BUS2    ',  138.0000,1,   1,   1,   1, 1.00000,   0.0000
48460 / END OF BUS DATA
48470 / END OF LOAD DATA
48480 / END OF FIXED SHUNT DATA
4849    1,'1 ',  100.000,    0.000,  300.000, -300.000, 1.06000,    0,  100.000,  0.0,  1.0,  0.0,  0.0,  1.0,1, 100.0,  250.000,    10.000
48500 / END OF GENERATOR DATA
48510 / END OF NON-TRANSFORMER BRANCH DATA
4852    1,     2,     0,'1 ',1,1,1,  0.00000,  0.00000,2,'            ',1,1,1.00000,0,1.00000,0,1.00000,0,0,  0.00000,'            '
4853   0.00000, 0.10000,  100.00
4854   1.05000,    0.000,   0.000,  100.00,    0.00,    0.00,   0,   0, 1.10000, 0.90000, 1.10000, 0.90000,  33,   0, 0.00000, 0.00000,  0.000
4855   1.00000,    0.000
48560 / END OF TRANSFORMER DATA
4857Q
4858"#;
4859        let net = parse_str(raw).expect("failed to parse PSS/E with transformer");
4860
4861        assert_eq!(net.n_buses(), 2);
4862        assert_eq!(net.n_branches(), 1); // just the transformer
4863        assert_eq!(net.generators.len(), 1);
4864
4865        let xfmr = &net.branches[0];
4866        assert_eq!(xfmr.from_bus, 1);
4867        assert_eq!(xfmr.to_bus, 2);
4868        assert!((xfmr.x - 0.10).abs() < 1e-10);
4869        // CW=1: tap = windv1/windv2 = 1.05/1.0 = 1.05
4870        assert!((xfmr.tap - 1.05).abs() < 1e-10);
4871        assert!(xfmr.in_service);
4872    }
4873
4874    #[test]
4875    fn test_parse_with_fixed_shunt() {
4876        let raw = r#"0,   100.00, 33, 0, 0, 60.00
4877Test
4878Test
4879    1,'BUS1    ',  138.0000,3,   1,   1,   1, 1.06000,   0.0000
4880    2,'BUS2    ',  138.0000,1,   1,   1,   1, 1.00000,   0.0000
48810 / END OF BUS DATA
48820 / END OF LOAD DATA
4883    2,'1 ',1,   0.000,  19.000
48840 / END OF FIXED SHUNT DATA
4885    1,'1 ',  100.000,    0.000,  300.000, -300.000, 1.06000,    0,  100.000,  0.0,  1.0,  0.0,  0.0,  1.0,1, 100.0,  250.000,    10.000
48860 / END OF GENERATOR DATA
4887    1,     2,'1 ', 0.01000, 0.10000, 0.02000,  100.00,  100.00,  100.00,  0.00000,  0.00000,  0.00000,  0.00000,1,1,   0.000,0,0,0,0,0
48880 / END OF NON-TRANSFORMER BRANCH DATA
48890 / END OF TRANSFORMER DATA
4890Q
4891"#;
4892        let net = parse_str(raw).expect("failed to parse PSS/E with shunt");
4893
4894        // Fixed shunt should be applied to bus 2
4895        let bus2 = net.buses.iter().find(|b| b.number == 2).unwrap();
4896        assert!((bus2.shunt_susceptance_mvar - 19.0).abs() < 1e-10);
4897    }
4898
4899    #[test]
4900    fn test_parse_status_rejects_empty_and_unknown() {
4901        assert!(parse_status("", 1, "STAT").is_err());
4902        assert!(parse_status("maybe", 1, "STAT").is_err());
4903        assert_eq!(parse_status("1", 1, "STAT").unwrap(), 1);
4904        assert_eq!(parse_status("0", 1, "STAT").unwrap(), 0);
4905    }
4906
4907    #[test]
4908    fn test_dc_converter_record_requires_status_field() {
4909        let fields = vec![
4910            "1".to_string(),
4911            "1".to_string(),
4912            "30".to_string(),
4913            "5".to_string(),
4914            "0.1".to_string(),
4915            "0.1".to_string(),
4916            "230".to_string(),
4917            "1".to_string(),
4918            "1".to_string(),
4919            "1".to_string(),
4920            "1".to_string(),
4921            "1".to_string(),
4922            "".to_string(),
4923        ];
4924        assert!(
4925            parse_dc_converter_record(&fields, 1).is_err(),
4926            "blank IC must be rejected"
4927        );
4928    }
4929
4930    #[test]
4931    fn test_vsc_converter_record_requires_state_field() {
4932        let fields = vec![
4933            "1".to_string(),
4934            "1".to_string(),
4935            "2".to_string(),
4936            "0.0".to_string(),
4937            "1.0".to_string(),
4938            "0.0".to_string(),
4939            "0.0".to_string(),
4940            "0.0".to_string(),
4941            "100.0".to_string(),
4942            "-100.0".to_string(),
4943            "50.0".to_string(),
4944            "-50.0".to_string(),
4945            "1.0".to_string(),
4946            "".to_string(),
4947        ];
4948        assert!(
4949            parse_vsc_converter_record(&fields, 1).is_err(),
4950            "blank STATE must be rejected"
4951        );
4952    }
4953
4954    #[test]
4955    fn test_parse_ieee14_raw() {
4956        let path = test_data_dir().join("IEEE_14_bus.raw");
4957        if !path.exists() {
4958            return; // skip if test data not downloaded
4959        }
4960        let net = parse_file(&path).expect("failed to parse IEEE 14 bus RAW");
4961        assert_eq!(net.n_buses(), 14);
4962        assert_eq!(net.n_branches(), 20);
4963        assert_eq!(net.generators.len(), 5);
4964        assert!(net.total_load_mw() > 250.0);
4965    }
4966
4967    #[test]
4968    fn test_parse_ieee30_raw() {
4969        let path = test_data_dir().join("IEEE_30_bus.raw");
4970        if !path.exists() {
4971            return;
4972        }
4973        let net = parse_file(&path).expect("failed to parse IEEE 30 bus RAW");
4974        assert_eq!(net.n_buses(), 30);
4975        assert_eq!(net.n_branches(), 41);
4976        assert_eq!(net.generators.len(), 6);
4977    }
4978
4979    #[test]
4980    fn test_parse_ieee57_raw() {
4981        let path = test_data_dir().join("IEEE_57_bus.raw");
4982        if !path.exists() {
4983            return;
4984        }
4985        let net = parse_file(&path).expect("failed to parse IEEE 57 bus RAW");
4986        assert_eq!(net.n_buses(), 57);
4987        assert_eq!(net.n_branches(), 80);
4988        assert_eq!(net.generators.len(), 7);
4989    }
4990
4991    #[test]
4992    fn test_parse_ieee118_raw() {
4993        let path = test_data_dir().join("IEEE_118_bus.raw");
4994        if !path.exists() {
4995            return;
4996        }
4997        let net = parse_file(&path).expect("failed to parse IEEE 118 bus RAW");
4998        assert_eq!(net.n_buses(), 118);
4999        assert_eq!(net.n_branches(), 186);
5000        assert_eq!(net.generators.len(), 54);
5001    }
5002
5003    /// PSS/E v36 file with @!IC header line and @! section annotations (240-bus WECC).
5004    #[test]
5005    fn test_parse_wecc_240_v36_with_annotations() {
5006        let path = test_data_dir()
5007            .join("raw")
5008            .join("240busWECC_2018_PSS_PQLoad_fixedshunt_noremotebus_yuan_v36.raw");
5009        if !path.exists() {
5010            return;
5011        }
5012        let net = parse_file(&path).expect("failed to parse WECC 240-bus v36 RAW");
5013        // The "240-bus WECC" model has 243 buses in the RAW file (240 network
5014        // buses + 3 generator step-up transformer low-side buses).
5015        assert_eq!(net.n_buses(), 243, "bus count");
5016        assert!(net.n_branches() > 0, "must have branches");
5017        assert!(!net.generators.is_empty(), "must have generators");
5018        // This particular v36 file does not include LATITUDE/LONGITUDE fields
5019        // in the bus records (PSS/E v35+ supports them but they are optional).
5020    }
5021
5022    /// PSS/E v34 file with @! section annotations (240-bus WECC, fixed-shunt variant).
5023    #[test]
5024    fn test_parse_wecc_240_v34_with_annotations() {
5025        let path = test_data_dir()
5026            .join("raw")
5027            .join("240busWECC_2018_PSS_fixedshunt.raw");
5028        if !path.exists() {
5029            return;
5030        }
5031        let net = parse_file(&path).expect("failed to parse WECC 240-bus v34 RAW");
5032        // Same model as v36; both RAW files contain 243 buses (240 network + 3 GSU).
5033        assert_eq!(net.n_buses(), 243, "bus count");
5034        assert!(net.n_branches() > 0, "must have branches");
5035    }
5036
5037    /// Cross-format validation: PSS/E RAW vs MATPOWER for IEEE 14 bus
5038    #[test]
5039    fn test_cross_format_ieee14_raw_vs_matpower() {
5040        let raw_path = test_data_dir().join("IEEE_14_bus.raw");
5041        let m_path = test_data_dir().join("case14.m");
5042        if !raw_path.exists() {
5043            return;
5044        }
5045
5046        let raw_net = parse_file(&raw_path).expect("failed to parse RAW");
5047        let m_net = crate::matpower::load(&m_path).expect("failed to parse MATPOWER");
5048
5049        // Same topology
5050        assert_eq!(raw_net.n_buses(), m_net.n_buses());
5051        assert_eq!(raw_net.n_branches(), m_net.n_branches());
5052        assert_eq!(raw_net.generators.len(), m_net.generators.len());
5053
5054        // Same total load (within tolerance)
5055        assert!(
5056            (raw_net.total_load_mw() - m_net.total_load_mw()).abs() < 1.0,
5057            "Load mismatch: RAW={:.1}, MATPOWER={:.1}",
5058            raw_net.total_load_mw(),
5059            m_net.total_load_mw()
5060        );
5061    }
5062
5063    #[test]
5064    fn test_psse_v35_substation_data() {
5065        // Minimal v35 RAW file with substation data section.
5066        let raw = r#"@!IC,SBASE,REV,XFRRAT,NXFRAT,BASFRQ
5067 0, 100.00, 35, 0, 0, 60.00
5068CASE HEADING 1
5069CASE HEADING 2
5070    1, 'BUS 1', 138.0, 3, 1, 1, 1, 1.060, 0.0, 1.1, 0.9
5071    2, 'BUS 2', 138.0, 1, 1, 1, 1, 1.045, -5.0, 1.1, 0.9
50720 / END OF BUS DATA, BEGIN LOAD DATA
5073    2, '1 ', 1, 1, 1, 21.7, 12.7, 0.0, 0.0, 0.0, 0.0, 1
50740 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
50750 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
5076    1, '1 ', 40.0, 0.0, 999.0, -999.0, 1.060, 0, 100.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1, 100.0, 999.0, 0.0, 1, 1.0, 0, 0, 1.0, 0.0, 0, 0.0
50770 / END OF GENERATOR DATA, BEGIN BRANCH DATA
5078    1, 2, '1 ', 0.01938, 0.05917, 0.05280, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 1, 0.0, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0
50790 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA
50800 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
50810 / END OF TRANSFORMER DATA, BEGIN AREA DATA
50820 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
50830 / END OF TWO-TERMINAL DC DATA, BEGIN VOLTAGE SOURCE CONVERTER DATA
50840 / END OF VOLTAGE SOURCE CONVERTER DATA, BEGIN IMPEDANCE CORRECTION DATA
50850 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
50860 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
50870 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
50880 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
50890 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
50900 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA
50910 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA
50920 /END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
50930 /END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA
50940 /END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA
5095	1,	'STATION 1',	0.0,	0.0,	0.1
5096	  / BEGIN SUBSTATION NODE DATA
5097		1,	'NB1',	1,	1,	1.0,	0.0
5098		2,	'NB2',	1,	1,	1.0,	0.0
5099		3,	'NL2',	1,	1,	1.0,	0.0
5100		4,	'NG1',	1,	1,	1.0,	0.0
5101	0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA
5102		1,	2,	'1 ',	'Sw-BusBars',		2,	1,	1,	0,	0,	0,	0
5103		1,	3,	'1 ',	'Sw-Branch2',	2,	1,	1,	0,	0,	0,	0
5104		2,	4,	'1 ',	'Sw-Gen1',		2,	1,	1,	0,	0,	0,	0
5105	0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA
5106		1,	4,	'M', '1 '
5107		1,	3,	'B',  2,	'1 '
5108	0 / END OF SUBSTATION TERMINAL DATA
51090 /END OF SUBSTATION DATA
5110Q
5111"#;
5112
5113        let net = parse_str(raw).expect("failed to parse v35 RAW with substation data");
5114        assert_eq!(net.n_buses(), 2);
5115        assert_eq!(net.n_branches(), 1);
5116        assert_eq!(net.generators.len(), 1);
5117
5118        // Verify NodeBreakerTopology is present and populated.
5119        let sm = net.topology.as_ref().expect("topology should be Some");
5120        assert_eq!(sm.substations.len(), 1, "should have 1 substation");
5121        assert_eq!(
5122            sm.connectivity_nodes.len(),
5123            4,
5124            "should have 4 connectivity nodes"
5125        );
5126        assert_eq!(sm.switches.len(), 3, "should have 3 switches");
5127        assert_eq!(
5128            sm.terminal_connections.len(),
5129            2,
5130            "should have 2 terminal connections"
5131        );
5132
5133        // All switches should be closed (status=1 → open=false).
5134        for sw in &sm.switches {
5135            assert!(!sw.open, "switch {} should be closed", sw.id);
5136            assert_eq!(sw.switch_type, SwitchType::Breaker);
5137        }
5138
5139        // Topology reduction: all 4 nodes map to bus 1 (all connected by closed
5140        // switches in the same substation, all mapped to IBUS=1).
5141        let reduction = sm
5142            .current_mapping()
5143            .expect("topology reduction should exist");
5144        assert_eq!(reduction.connectivity_node_to_bus.len(), 4);
5145        for cn_id in reduction.connectivity_node_to_bus.keys() {
5146            assert_eq!(
5147                reduction.connectivity_node_to_bus[cn_id], 1,
5148                "all nodes in station 1 should map to bus 1"
5149            );
5150        }
5151
5152        // Busbar sections: NB1 and NB2 should be detected.
5153        assert_eq!(sm.busbar_sections.len(), 2, "should detect NB1 and NB2");
5154    }
5155
5156    #[test]
5157    fn test_psse_v35_sys_switching_device() {
5158        // v35 file with a system switching device (inter-bus, between branches and transformers).
5159        let raw = r#"@!IC,SBASE,REV,XFRRAT,NXFRAT,BASFRQ
5160 0, 100.00, 35, 0, 0, 60.00
5161CASE HEADING 1
5162CASE HEADING 2
5163    1, 'BUS 1', 138.0, 3, 1, 1, 1, 1.060, 0.0, 1.1, 0.9
5164    2, 'BUS 2', 138.0, 1, 1, 1, 1, 1.045, -5.0, 1.1, 0.9
51650 / END OF BUS DATA, BEGIN LOAD DATA
51660 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
51670 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
51680 / END OF GENERATOR DATA, BEGIN BRANCH DATA
51690 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA
5170    1, 2, '1 ', 'BRK_1_2', 2, 1, 1, 0.0001, 0.0, 0.0, 0.0
51710 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
51720 / END OF TRANSFORMER DATA, BEGIN AREA DATA
51730 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
51740 / END OF TWO-TERMINAL DC DATA, BEGIN VOLTAGE SOURCE CONVERTER DATA
51750 / END OF VOLTAGE SOURCE CONVERTER DATA, BEGIN IMPEDANCE CORRECTION DATA
51760 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
51770 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
51780 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
51790 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
51800 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
51810 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA
51820 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA
51830 /END OF SWITCHED SHUNT DATA
5184Q
5185"#;
5186
5187        let net = parse_str(raw).expect("failed to parse v35 with sys switching device");
5188        assert_eq!(net.n_buses(), 2);
5189
5190        // Should have NodeBreakerTopology from system switching device.
5191        let sm = net
5192            .topology
5193            .as_ref()
5194            .expect("topology should be Some for sys switch devices");
5195        assert_eq!(sm.switches.len(), 1, "should have 1 sys switch device");
5196        assert_eq!(sm.switches[0].switch_type, SwitchType::Breaker);
5197        assert!(!sm.switches[0].open, "switch should be closed (status=1)");
5198        assert_eq!(
5199            sm.connectivity_nodes.len(),
5200            2,
5201            "should have 2 CNs for the two bus endpoints"
5202        );
5203    }
5204
5205    /// Issue #28: INDUCTION MACHINE DATA section parsed from v35 RAW.
5206    #[test]
5207    fn test_parse_induction_machine_data() {
5208        let raw = r#" 0, 100.0, 35, 0, 0, 60.0 /  PSS/E 35 Raw Data
5209 Test Case
5210 Exported by Surge
5211     1,'BUS1',138.0,3,1,1,1,1.0,0.0,1
52120 / END OF BUS DATA, BEGIN LOAD DATA
52130 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
52140 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
52150 / END OF GENERATOR DATA, BEGIN BRANCH DATA
52160 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA
52170 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
52180 / END OF TRANSFORMER DATA, BEGIN AREA DATA
52190 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
52200 / END OF TWO-TERMINAL DC DATA, BEGIN VOLTAGE SOURCE CONVERTER DATA
52210 / END OF VOLTAGE SOURCE CONVERTER DATA, BEGIN IMPEDANCE CORRECTION DATA
52220 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
52230 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
52240 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
52250 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
52260 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
52270 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA
52280 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA
52290 /END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
52300 /END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA
5231    1,'M1',1,1,3,1,1,1,1,1,5.0,4.16,1,3.0,1.5,0.0,1.0,0.0,0.0,0.0
5232    0.01,0.05,2.5,0.03,0.08,0.0,0.0,0.0,1.0,0.0,1.2,0.0,0.0,0.0,1.0
52330 /END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA
52340 /END OF SUBSTATION DATA
5235Q
5236"#;
5237        let net = parse_str(raw).expect("failed to parse v35 RAW with induction machine");
5238        assert_eq!(
5239            net.induction_machines.len(),
5240            1,
5241            "expected 1 induction machine"
5242        );
5243        let m = &net.induction_machines[0];
5244        assert_eq!(m.bus, 1);
5245        assert_eq!(m.id.trim_matches('\'').trim(), "M1");
5246        assert!(m.in_service);
5247        assert!((m.mbase - 5.0).abs() < 1e-9, "mbase={}", m.mbase);
5248        assert!((m.h - 1.5).abs() < 1e-9, "H={}", m.h);
5249        assert!((m.ra - 0.01).abs() < 1e-9, "ra={}", m.ra);
5250        assert!((m.xm - 2.5).abs() < 1e-9, "xm={}", m.xm);
5251        assert!((m.r1 - 0.03).abs() < 1e-9, "r1={}", m.r1);
5252    }
5253}