Skip to main content

surge_io/matpower/
reader.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! MATPOWER (.m) case file parser.
3//!
4//! Parses the MATPOWER case format, which defines bus, generator, branch,
5//! and other data in MATLAB struct arrays. This is the primary format for
6//! academic test cases (IEEE, PEGASE, ACTIVSg, Polish, RTE).
7//!
8//! # Supported sections
9//! - `mpc.baseMVA` — System base power
10//! - `mpc.bus` — Bus data (13 columns)
11//! - `mpc.gen` — Generator data (10+ columns)
12//! - `mpc.branch` — Branch data (13 columns)
13//!
14//! # Column mappings
15//! See MATPOWER's `caseformat.m` for the canonical column definitions.
16
17use std::path::Path;
18
19use surge_network::Network;
20use surge_network::market::CostCurve;
21use surge_network::network::{
22    Branch, BranchType, Bus, BusType, DcBranch, DcBus, DcConverter, DcConverterStation, FuelParams,
23    GenType, Generator, GeneratorTechnology, Load,
24};
25use thiserror::Error;
26
27/// Convert a MATPOWER/PSS/E integer bus type code to `BusType`.
28fn bus_type_from_matpower(code: u32) -> BusType {
29    match code {
30        2 => BusType::PV,
31        3 => BusType::Slack,
32        4 => BusType::Isolated,
33        _ => BusType::PQ,
34    }
35}
36
37#[derive(Error, Debug)]
38pub enum MatpowerError {
39    #[error("I/O error: {0}")]
40    Io(#[from] std::io::Error),
41
42    #[error("parse error on line {line}: {message}")]
43    Parse { line: usize, message: String },
44
45    #[error("missing required section: {0}")]
46    MissingSection(String),
47
48    #[error("insufficient columns in {section} row {row}: expected {expected}, got {got}")]
49    InsufficientColumns {
50        section: String,
51        row: usize,
52        expected: usize,
53        got: usize,
54    },
55
56    #[error("invalid gencost: {0}")]
57    InvalidGencost(String),
58
59    #[error("invalid float value: {0}")]
60    InvalidFloat(String),
61
62    #[error("non-finite value in {section} row {row}: {field} = {value}")]
63    NonFiniteValue {
64        section: &'static str,
65        row: usize,
66        field: &'static str,
67        value: f64,
68    },
69
70    #[error("expression nesting too deep: {0}")]
71    ExpressionTooDeep(String),
72
73    #[error("file too large: {0} bytes exceeds limit of {1} bytes")]
74    FileTooLarge(u64, u64),
75}
76
77/// Parse a MATPOWER .m case file from disk.
78pub fn parse_file(path: &Path) -> Result<Network, MatpowerError> {
79    const MAX_FILE_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
80    let metadata = std::fs::metadata(path)?;
81    if metadata.len() > MAX_FILE_SIZE {
82        return Err(MatpowerError::FileTooLarge(metadata.len(), MAX_FILE_SIZE));
83    }
84    let content = std::fs::read_to_string(path)?;
85    parse_str(&content)
86}
87
88/// Parse a MATPOWER case from a string.
89pub fn parse_str(content: &str) -> Result<Network, MatpowerError> {
90    let mut name = String::from("unknown");
91    let mut base_mva = 100.0;
92    let mut bus_rows: Vec<Vec<f64>> = Vec::new();
93    let mut gen_rows: Vec<Vec<f64>> = Vec::new();
94    let mut branch_rows: Vec<Vec<f64>> = Vec::new();
95    let mut gencost_rows: Vec<Vec<f64>> = Vec::new();
96    let mut bus_name_rows: Vec<String> = Vec::new();
97    let mut gentype_rows: Vec<String> = Vec::new();
98    let mut genfuel_rows: Vec<String> = Vec::new();
99    let mut dc_bus_rows: Vec<Vec<f64>> = Vec::new();
100    let mut dc_conv_rows: Vec<Vec<f64>> = Vec::new();
101    let mut dc_branch_rows: Vec<Vec<f64>> = Vec::new();
102
103    #[derive(PartialEq)]
104    enum Section {
105        None,
106        Bus,
107        Gen,
108        Branch,
109        GenCost,
110        BusName,
111        GenType,
112        GenFuel,
113        DcBus,
114        DcConv,
115        DcBranch,
116        Other,
117    }
118    let mut section = Section::None;
119
120    for (line_idx, raw_line) in content.lines().enumerate() {
121        let line_num = line_idx + 1;
122
123        // Strip inline comments (% to end of line).
124        // Be careful not to strip % inside strings, but MATPOWER numeric data
125        // never contains quoted strings in data sections.
126        let line = match raw_line.find('%') {
127            Some(idx) => &raw_line[..idx],
128            None => raw_line,
129        };
130        let line = line.trim();
131
132        if line.is_empty() {
133            continue;
134        }
135
136        // Check for cell array section end: `};` (used by mpc.bus_name and similar)
137        if line.contains("};")
138            && matches!(
139                section,
140                Section::BusName | Section::GenType | Section::GenFuel
141            )
142        {
143            section = Section::None;
144            continue;
145        }
146
147        // Check for section end: `];` or `};` (pglib-opf-hvdc uses curly braces)
148        let is_bracket_end = line.contains("];");
149        let is_brace_end = line.contains("};");
150        if is_bracket_end || is_brace_end {
151            // Parse any data before the closing delimiter on the same line
152            if section == Section::Bus
153                || section == Section::Gen
154                || section == Section::Branch
155                || section == Section::GenCost
156                || section == Section::DcBus
157                || section == Section::DcConv
158                || section == Section::DcBranch
159            {
160                let delim = if is_bracket_end { "];" } else { "};" };
161                let data_part = line.split(delim).next().unwrap_or("").trim();
162                let data_part = data_part
163                    .trim_end_matches(';')
164                    .trim_end_matches(']')
165                    .trim_end_matches('}')
166                    .trim();
167                if !data_part.is_empty()
168                    && let Some(row) = parse_numeric_row(data_part)
169                {
170                    match section {
171                        Section::Bus => bus_rows.push(row),
172                        Section::Gen => gen_rows.push(row),
173                        Section::Branch => branch_rows.push(row),
174                        Section::GenCost => gencost_rows.push(row),
175                        Section::DcBus => dc_bus_rows.push(row),
176                        Section::DcConv => dc_conv_rows.push(row),
177                        Section::DcBranch => dc_branch_rows.push(row),
178                        _ => {}
179                    }
180                }
181            }
182            section = Section::None;
183            continue;
184        }
185
186        // Function declaration: `function mpc = case9`
187        if line.starts_with("function") {
188            if let Some(eq_pos) = line.find('=') {
189                name = line[eq_pos + 1..]
190                    .trim()
191                    .trim_end_matches(';')
192                    .trim()
193                    .to_string();
194            }
195            continue;
196        }
197
198        // baseMVA: `mpc.baseMVA = 100;` or `baseMVA = 100;`
199        // Must NOT match `Sbase = mpc.baseMVA * 1e6;` (baseMVA on RHS)
200        if line.contains("baseMVA") && line.contains('=') {
201            if let Some(eq_pos) = line.find('=') {
202                let lhs = line[..eq_pos].trim();
203                if lhs == "mpc.baseMVA" || lhs == "baseMVA" {
204                    let val_str = line[eq_pos + 1..].trim().trim_end_matches(';').trim();
205                    base_mva = eval_simple_expr(val_str).ok_or_else(|| MatpowerError::Parse {
206                        line: line_num,
207                        message: format!("invalid baseMVA value: '{val_str}'"),
208                    })?;
209                }
210            }
211            continue;
212        }
213
214        // Section start detection
215        if is_section_start(line, "bus") {
216            section = Section::Bus;
217            try_parse_inline_data(line, &mut bus_rows);
218            continue;
219        }
220        if is_section_start(line, "gencost") {
221            section = Section::GenCost;
222            try_parse_inline_data(line, &mut gencost_rows);
223            continue;
224        }
225        if is_section_start(line, "gen") {
226            section = Section::Gen;
227            try_parse_inline_data(line, &mut gen_rows);
228            continue;
229        }
230        if is_section_start(line, "branch") {
231            section = Section::Branch;
232            try_parse_inline_data(line, &mut branch_rows);
233            continue;
234        }
235
236        // DC network DC bus section: mpc.busdc or mpc.dcbus
237        if is_section_start(line, "busdc") || is_section_start(line, "dcbus") {
238            section = Section::DcBus;
239            try_parse_inline_data(line, &mut dc_bus_rows);
240            continue;
241        }
242        // DC network DC converter section: mpc.convdc or mpc.dcconv
243        if is_section_start(line, "convdc") || is_section_start(line, "dcconv") {
244            section = Section::DcConv;
245            try_parse_inline_data(line, &mut dc_conv_rows);
246            continue;
247        }
248        // DC network DC branch section: mpc.branchdc or mpc.dcbranch
249        if is_section_start(line, "branchdc") || is_section_start(line, "dcbranch") {
250            section = Section::DcBranch;
251            try_parse_inline_data(line, &mut dc_branch_rows);
252            continue;
253        }
254
255        // bus_name cell array: `mpc.bus_name = {`
256        if line.contains("mpc.bus_name") && line.contains('=') && line.contains('{') {
257            section = Section::BusName;
258            continue;
259        }
260        if line.contains("mpc.gentype") && line.contains('=') && line.contains('{') {
261            section = Section::GenType;
262            continue;
263        }
264        if line.contains("mpc.genfuel") && line.contains('=') && line.contains('{') {
265            section = Section::GenFuel;
266            continue;
267        }
268
269        // Other mpc.xxx sections — skip their contents
270        if line.contains("mpc.") && line.contains('=') {
271            if section != Section::Bus
272                && section != Section::Gen
273                && section != Section::Branch
274                && section != Section::DcBus
275                && section != Section::DcConv
276                && section != Section::DcBranch
277            {
278                section = Section::Other;
279            }
280            continue;
281        }
282
283        // Parse data rows within active sections
284        match section {
285            Section::Bus
286            | Section::Gen
287            | Section::Branch
288            | Section::GenCost
289            | Section::DcBus
290            | Section::DcConv
291            | Section::DcBranch => {
292                let row_str = line.trim_end_matches(';').trim();
293                if let Some(row) = parse_numeric_row(row_str) {
294                    match section {
295                        Section::Bus => bus_rows.push(row),
296                        Section::Gen => gen_rows.push(row),
297                        Section::Branch => branch_rows.push(row),
298                        Section::GenCost => gencost_rows.push(row),
299                        Section::DcBus => dc_bus_rows.push(row),
300                        Section::DcConv => dc_conv_rows.push(row),
301                        Section::DcBranch => dc_branch_rows.push(row),
302                        _ => {}
303                    }
304                }
305            }
306            Section::BusName => {
307                // Each entry looks like: `\t'Riversde  V2';`
308                // Only process lines that contain a quoted string.
309                if line.contains('\'') {
310                    bus_name_rows.push(parse_cell_array_entry(line));
311                }
312            }
313            Section::GenType => {
314                if line.contains('\'') {
315                    gentype_rows.push(parse_cell_array_entry(line));
316                }
317            }
318            Section::GenFuel => {
319                if line.contains('\'') {
320                    genfuel_rows.push(parse_cell_array_entry(line));
321                }
322            }
323            _ => {}
324        }
325    }
326
327    // Validate we got the essential sections
328    if bus_rows.is_empty() {
329        return Err(MatpowerError::MissingSection("bus".into()));
330    }
331    if branch_rows.is_empty() {
332        return Err(MatpowerError::MissingSection("branch".into()));
333    }
334
335    // Detect distribution case unit conversions from MATLAB code in the file
336    let conversions = detect_conversions(content);
337
338    // Build network
339    let mut network = Network::new(&name);
340    network.base_mva = base_mva;
341
342    // Temporary storage for bus-level pd/qd before Load creation.
343    let mut bus_pd_qd: Vec<(u32, f64, f64)> = Vec::new();
344
345    // Parse buses (13 columns minimum)
346    for (i, row) in bus_rows.iter().enumerate() {
347        if row.len() < 13 {
348            return Err(MatpowerError::InsufficientColumns {
349                section: "bus".into(),
350                row: i + 1,
351                expected: 13,
352                got: row.len(),
353            });
354        }
355        network.buses.push(Bus {
356            number: row[0] as u32,
357            name: String::new(),
358            bus_type: bus_type_from_matpower(row[1] as u32),
359            shunt_conductance_mw: row[4],
360            shunt_susceptance_mvar: row[5],
361            area: row[6] as u32,
362            voltage_magnitude_pu: row[7],
363            voltage_angle_rad: row[8].to_radians(), // MATPOWER stores degrees
364            base_kv: row[9],
365            zone: row[10] as u32,
366            voltage_max_pu: row[11],
367            voltage_min_pu: row[12],
368            // Optional solved-case columns (lam/mu) are intentionally ignored.
369            island_id: 0,
370            latitude: None, // lat/lon not in standard MATPOWER bus matrix
371            longitude: None,
372            ..Bus::new(0, BusType::PQ, 0.0)
373        });
374        // Store raw pd/qd for later Load creation (before conversions).
375        bus_pd_qd.push((row[0] as u32, row[2], row[3]));
376        // M-2: Validate critical bus fields for finiteness
377        let row_num = i + 1;
378        check_finite(row[2], "bus", row_num, "pd")?;
379        check_finite(row[3], "bus", row_num, "qd")?;
380        check_finite(row[4], "bus", row_num, "gs")?;
381        check_finite(row[5], "bus", row_num, "bs")?;
382        check_finite(row[7], "bus", row_num, "vm")?;
383        check_finite(row[8], "bus", row_num, "va")?;
384        check_finite(row[9], "bus", row_num, "base_kv")?;
385        check_finite(row[11], "bus", row_num, "vmax")?;
386        check_finite(row[12], "bus", row_num, "vmin")?;
387    }
388
389    // MP-05: Build a set of valid bus numbers so we can validate generator bus references.
390    // A generator referencing a non-existent bus causes a panic later in Y-bus construction.
391    let bus_set: std::collections::HashSet<u32> = network.buses.iter().map(|b| b.number).collect();
392    let mut gen_row_to_network_idx: Vec<Option<usize>> = Vec::with_capacity(gen_rows.len());
393
394    // Parse generators (first 10 columns of 21)
395    for (i, row) in gen_rows.iter().enumerate() {
396        if row.len() < 10 {
397            return Err(MatpowerError::InsufficientColumns {
398                section: "gen".into(),
399                row: i + 1,
400                expected: 10,
401                got: row.len(),
402            });
403        }
404        let gen_bus_number = row[0] as u32;
405        // MP-05: Reject generators whose bus does not exist in the parsed bus list.
406        if !bus_set.contains(&gen_bus_number) {
407            return Err(MatpowerError::Parse {
408                line: i + 1,
409                message: format!("generator at bus {gen_bus_number} references missing bus"),
410            });
411        }
412        // Optional MATPOWER cols 16-18 (0-indexed): RAMP_AGC, RAMP_10, RAMP_30
413        // MATPOWER units: RAMP_AGC in MW/min; RAMP_10 in MW/10-min; RAMP_30 in MW/30-min.
414        // We normalize all ramp fields to MW/min for consistent internal representation.
415        // ramp_agc → reg_ramp_up_curve, ramp_10 → ramp_up_curve (preferred over ramp_30).
416        let reg_ramp_up_curve: Vec<(f64, f64)> = row
417            .get(16)
418            .copied()
419            .filter(|&v| v.abs() > 1e-20)
420            .map(|v| vec![(0.0, v)])
421            .unwrap_or_default();
422        let ramp_up_curve: Vec<(f64, f64)> = row
423            .get(17)
424            .copied()
425            .filter(|&v| v.abs() > 1e-20)
426            .map(|v| vec![(0.0, v / 10.0)])
427            .or_else(|| {
428                row.get(18)
429                    .copied()
430                    .filter(|&v| v.abs() > 1e-20)
431                    .map(|v| vec![(0.0, v / 30.0)])
432            })
433            .unwrap_or_default();
434        let ramping = if !reg_ramp_up_curve.is_empty() || !ramp_up_curve.is_empty() {
435            Some(surge_network::network::RampingParams {
436                reg_ramp_up_curve,
437                ramp_up_curve,
438                ..Default::default()
439            })
440        } else {
441            None
442        };
443
444        // Reactive capability curve (MATPOWER cols 10-15, 0-indexed)
445        let reactive_capability = {
446            let pc1_val = row.get(10).copied();
447            let pc2_val = row.get(11).copied();
448            let qc1min_val = row.get(12).copied();
449            let qc1max_val = row.get(13).copied();
450            let qc2min_val = row.get(14).copied();
451            let qc2max_val = row.get(15).copied();
452            // Convert MATPOWER two-point capability curve to pq_curve.
453            // pc1/pc2 are P breakpoints in MW; qcX values are reactive limits in MVAr.
454            // pq_curve stores per-unit on system base: (p_pu, qmax_pu, qmin_pu).
455            let pc1_f = pc1_val.unwrap_or(0.0);
456            let pc2_f = pc2_val.unwrap_or(0.0);
457            let pq_curve = match (row.get(12), row.get(13), row.get(14), row.get(15)) {
458                (Some(&qc1min), Some(&qc1max), Some(&qc2min), Some(&qc2max))
459                    if (pc2_f - pc1_f).abs() > 1e-6 =>
460                {
461                    let inv = 1.0 / base_mva;
462                    if pc1_f <= pc2_f {
463                        vec![
464                            (pc1_f * inv, qc1max * inv, qc1min * inv),
465                            (pc2_f * inv, qc2max * inv, qc2min * inv),
466                        ]
467                    } else {
468                        vec![
469                            (pc2_f * inv, qc2max * inv, qc2min * inv),
470                            (pc1_f * inv, qc1max * inv, qc1min * inv),
471                        ]
472                    }
473                }
474                _ => vec![],
475            };
476            let has_any = pc1_val.is_some()
477                || pc2_val.is_some()
478                || qc1min_val.is_some()
479                || qc1max_val.is_some()
480                || qc2min_val.is_some()
481                || qc2max_val.is_some()
482                || !pq_curve.is_empty();
483            if has_any {
484                Some(surge_network::network::ReactiveCapability {
485                    pc1: pc1_val,
486                    pc2: pc2_val,
487                    qc1min: qc1min_val,
488                    qc1max: qc1max_val,
489                    qc2min: qc2min_val,
490                    qc2max: qc2max_val,
491                    pq_curve,
492                    pq_linear_equality: None,
493                    pq_linear_upper: None,
494                    pq_linear_lower: None,
495                })
496            } else {
497                None
498            }
499        };
500
501        network.generators.push(Generator {
502            bus: gen_bus_number,
503            machine_id: None,
504            p: row[1],
505            q: row[2],
506            qmax: row[3],
507            qmin: row[4],
508            voltage_setpoint_pu: row[5],
509            reg_bus: None,
510            machine_base_mva: if row[6].is_finite() && row[6].abs() > 1e-10 {
511                row[6]
512            } else {
513                network.base_mva
514            },
515            in_service: row[7] as i32 > 0,
516            pmax: row[8],
517            pmin: row[9],
518            cost: None,
519            ramping,
520            reactive_capability,
521            // col 19 = ramp_q (skip — not a planning field)
522            // col 20 = agc_participation_factor
523            agc_participation_factor: row.get(20).copied(),
524            // Optional solved-case KKT multiplier columns are intentionally ignored.
525            forced_outage_rate: None,
526            h_inertia_s: None,
527            pfr_eligible: true,
528            ..Generator::new(0, 0.0, 1.0)
529        });
530        gen_row_to_network_idx.push(Some(network.generators.len() - 1));
531        // M-2: Validate critical generator fields for finiteness
532        let row_num = i + 1;
533        check_finite(row[1], "gen", row_num, "pg")?;
534        check_finite(row[2], "gen", row_num, "qg")?;
535        // qmax/qmin: Inf is valid in MATPOWER (means "unlimited"), only reject NaN
536        if row[3].is_nan() {
537            return Err(MatpowerError::NonFiniteValue {
538                section: "gen",
539                row: row_num,
540                field: "qmax",
541                value: row[3],
542            });
543        }
544        if row[4].is_nan() {
545            return Err(MatpowerError::NonFiniteValue {
546                section: "gen",
547                row: row_num,
548                field: "qmin",
549                value: row[4],
550            });
551        }
552        check_finite(row[5], "gen", row_num, "vs")?;
553        // mbase: NaN or 0 means "use system baseMVA" (standard MATPOWER convention)
554    }
555
556    // Parse gencost (optional — only present in OPF cases)
557    // MATPOWER format: [type, startup, shutdown, n, data...]
558    //   type 1 (piecewise-linear): data = x1, y1, x2, y2, ..., xn, yn
559    //   type 2 (polynomial): data = c_{n-1}, ..., c_1, c_0
560    for (i, row) in gencost_rows.iter().enumerate() {
561        if i >= gen_rows.len() {
562            break; // More gencost rows than generators (reactive cost rows) — skip
563        }
564        let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
565            continue;
566        };
567        if row.len() < 4 {
568            continue;
569        }
570        let cost_type = row[0] as i32;
571        let startup = row[1];
572        let shutdown = row[2];
573
574        // MP-01: Validate n before any allocation. A float like 1e300 casts to usize::MAX
575        // causing attempted 296-exabyte allocations. No valid gencost has >1000 breakpoints.
576        let n_raw = row[3];
577        if !(0.0..=1000.0).contains(&n_raw) || !n_raw.is_finite() {
578            return Err(MatpowerError::InvalidGencost(format!(
579                "gencost row {} has n={} breakpoints, must be 0–1000",
580                i + 1,
581                n_raw
582            )));
583        }
584        let n = n_raw as usize;
585
586        // Validate the row is long enough before indexing
587        let expected_len = 4 + if cost_type == 1 { 2 * n } else { n };
588        if row.len() < expected_len {
589            return Err(MatpowerError::InvalidGencost(format!(
590                "gencost row {} has {} fields, expected at least {}",
591                i + 1,
592                row.len(),
593                expected_len
594            )));
595        }
596
597        let cost = match cost_type {
598            2 => {
599                // Polynomial: n coefficients follow
600                Some(CostCurve::Polynomial {
601                    startup,
602                    shutdown,
603                    coeffs: row[4..4 + n].to_vec(),
604                })
605            }
606            1 => {
607                // Piecewise-linear: n (x,y) pairs follow
608                let points: Vec<(f64, f64)> = (0..n)
609                    .map(|j| (row[4 + 2 * j], row[4 + 2 * j + 1]))
610                    .collect();
611                Some(CostCurve::PiecewiseLinear {
612                    startup,
613                    shutdown,
614                    points,
615                })
616            }
617            _ => None,
618        };
619
620        if let Some(c) = cost {
621            network.generators[gen_idx].cost = Some(c);
622        }
623    }
624
625    for (i, raw_code) in gentype_rows.iter().enumerate() {
626        let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
627            continue;
628        };
629        let raw_code = raw_code.trim();
630        if raw_code.is_empty() {
631            continue;
632        }
633        let generator = &mut network.generators[gen_idx];
634        generator.source_technology_code = Some(raw_code.to_string());
635        generator.technology = Some(matpower_technology_from_code(raw_code));
636        if let Some(class) = matpower_electrical_class_from_code(raw_code) {
637            generator.gen_type = class;
638        }
639    }
640
641    for (i, raw_fuel) in genfuel_rows.iter().enumerate() {
642        let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
643            continue;
644        };
645        let raw_fuel = raw_fuel.trim();
646        if raw_fuel.is_empty() {
647            continue;
648        }
649        let generator = &mut network.generators[gen_idx];
650        generator
651            .fuel
652            .get_or_insert_with(FuelParams::default)
653            .fuel_type = Some(raw_fuel.to_string());
654        if generator.technology.is_none() {
655            generator.technology = matpower_technology_from_fuel(raw_fuel);
656        }
657        if generator.gen_type == GenType::Unknown
658            && let Some(class) = matpower_electrical_class_from_fuel(raw_fuel)
659        {
660            generator.gen_type = class;
661        }
662    }
663
664    // Parse branches (13 columns)
665    for (i, row) in branch_rows.iter().enumerate() {
666        if row.len() < 11 {
667            return Err(MatpowerError::InsufficientColumns {
668                section: "branch".into(),
669                row: i + 1,
670                expected: 11,
671                got: row.len(),
672            });
673        }
674        // MATPOWER convention: ratio = 0 means 1.0 for transmission lines
675        let tap = if row[8] == 0.0 { 1.0 } else { row[8] };
676
677        // MP-04: MATPOWER uses Inf (or 0.0) to signal an unconstrained branch rating.
678        // Surge uses 0.0 as the unconstrained sentinel. Convert Inf → 0.0 here.
679        let rate_a = if row[5].is_infinite() { 0.0 } else { row[5] };
680        let rate_b = if row[6].is_infinite() { 0.0 } else { row[6] };
681        let rate_c = if row[7].is_infinite() { 0.0 } else { row[7] };
682
683        network.branches.push(Branch {
684            from_bus: row[0] as u32,
685            to_bus: row[1] as u32,
686            circuit: "1".to_string(),
687            r: row[2],
688            x: row[3],
689            b: row[4],
690            rating_a_mva: rate_a,
691            rating_b_mva: rate_b,
692            rating_c_mva: rate_c,
693            tap,
694            phase_shift_rad: row[9].to_radians(),
695            in_service: row[10] as i32 > 0,
696            // Standard MATPOWER cols 11-12: angmin, angmax (degrees in file -> radians internally)
697            angle_diff_min_rad: row.get(11).copied().map(f64::to_radians),
698            angle_diff_max_rad: row.get(12).copied().map(f64::to_radians),
699            g_pi: 0.0,
700            g_mag: 0.0,
701            b_mag: 0.0,
702            tab: None,
703            // MATPOWER: tap != 1.0 or shift != 0.0 indicates a transformer
704            branch_type: if (tap - 1.0).abs() > 1e-6 || row[9].abs() > 1e-6 {
705                BranchType::Transformer
706            } else {
707                BranchType::Line
708            },
709            ..Branch::default()
710        });
711        // M-2: Validate critical branch fields for finiteness (skip rate_a — Inf→0.0 is valid)
712        let row_num = i + 1;
713        check_finite(row[2], "branch", row_num, "r")?;
714        check_finite(row[3], "branch", row_num, "x")?;
715        check_finite(row[4], "branch", row_num, "b")?;
716    }
717
718    // MP-05: MATPOWER has no circuit column, so parallel branches between the
719    // same (from_bus, to_bus) pair all got circuit "1" above.  Disambiguate by
720    // assigning "1", "2", … in file order for each bus pair.
721    {
722        let mut counts: std::collections::HashMap<(u32, u32), u32> =
723            std::collections::HashMap::new();
724        for branch in &mut network.branches {
725            let key = (branch.from_bus, branch.to_bus);
726            let n = counts.entry(key).or_insert(0);
727            *n += 1;
728            branch.circuit = n.to_string();
729        }
730    }
731
732    // Apply distribution case unit conversions
733    apply_conversions(&mut network, &conversions, &mut bus_pd_qd);
734
735    // Synthesize explicit Load objects from bus-level Pd/Qd.
736    // MUST happen after apply_conversions() so Load MW values match the converted values.
737    // MATPOWER stores loads in the bus matrix (columns 3-4). Other formats (PSS/E, CGMES)
738    // have separate load records, but MATPOWER embeds them on buses.
739    for &(bus_num, pd, qd) in &bus_pd_qd {
740        if pd.abs() > 1e-10 || qd.abs() > 1e-10 {
741            network.loads.push(Load::new(bus_num, pd, qd));
742        }
743    }
744
745    // Apply bus names from mpc.bus_name (positional — index i in bus_name = index i in mpc.bus).
746    // Trim trailing whitespace since PSS/E-originated files pad names to fixed width.
747    for (i, name) in bus_name_rows.iter().enumerate() {
748        if let Some(bus) = network.buses.get_mut(i)
749            && !name.is_empty()
750        {
751            bus.name = name.clone();
752        }
753    }
754
755    // MATPOWER convention: copy generator Vg to bus Vm for PV and REF buses.
756    // In MATPOWER's ext2int, gen(:, VG) overrides bus(:, VM) for generator buses.
757    // The last in-service generator at each bus sets the voltage setpoint.
758    {
759        let bus_map = network.bus_index_map();
760        for g in &network.generators {
761            if !g.in_service {
762                continue;
763            }
764            if let Some(&idx) = bus_map.get(&g.bus) {
765                let bt = network.buses[idx].bus_type;
766                if bt == BusType::PV || bt == BusType::Slack {
767                    network.buses[idx].voltage_magnitude_pu = g.voltage_setpoint_pu;
768                }
769            }
770        }
771    }
772
773    // Parse DC bus rows (8 columns: busdc_i, grid, Pdc, Vdc, basekVdc, Vdcmax, Vdcmin, Cdc)
774    for (row_idx, row) in dc_bus_rows.iter().enumerate() {
775        if row.len() < 7 {
776            return Err(MatpowerError::InsufficientColumns {
777                section: "busdc".to_string(),
778                row: row_idx,
779                expected: 7,
780                got: row.len(),
781            });
782        }
783        network
784            .hvdc
785            .ensure_dc_grid(row[1] as u32, None)
786            .buses
787            .push(DcBus {
788                bus_id: row[0] as u32,
789                p_dc_mw: row[2],
790                v_dc_pu: row[3],
791                base_kv_dc: row[4],
792                v_dc_max: row[5],
793                v_dc_min: row[6],
794                cost: row.get(7).copied().unwrap_or(0.0),
795                g_shunt_siemens: 0.0,
796                r_ground_ohm: 0.0,
797            });
798    }
799
800    // Parse DC converter rows (up to 34 columns)
801    for (row_idx, row) in dc_conv_rows.iter().enumerate() {
802        if row.len() < 22 {
803            return Err(MatpowerError::InsufficientColumns {
804                section: "convdc".to_string(),
805                row: row_idx,
806                expected: 22,
807                got: row.len(),
808            });
809        }
810        if let Some(dc_grid) = network.hvdc.find_dc_grid_by_bus_mut(row[0] as u32) {
811            dc_grid
812                .converters
813                .push(DcConverter::Vsc(DcConverterStation {
814                    id: String::new(),
815                    dc_bus: row[0] as u32,
816                    ac_bus: row[1] as u32,
817                    control_type_dc: row[2] as u32,
818                    control_type_ac: row[3] as u32,
819                    active_power_mw: row[4],
820                    reactive_power_mvar: row[5],
821                    is_lcc: row[6] as u32 != 0,
822                    voltage_setpoint_pu: row[7],
823                    transformer_r_pu: row[8],
824                    transformer_x_pu: row[9],
825                    transformer: row[10] as u32 != 0,
826                    tap_ratio: row[11],
827                    filter_susceptance_pu: row[12],
828                    filter: row[13] as u32 != 0,
829                    reactor_r_pu: row[14],
830                    reactor_x_pu: row[15],
831                    reactor: row[16] as u32 != 0,
832                    base_kv_ac: row.get(17).copied().unwrap_or(0.0),
833                    voltage_max_pu: row.get(18).copied().unwrap_or(1.1),
834                    voltage_min_pu: row.get(19).copied().unwrap_or(0.9),
835                    current_max_pu: row.get(20).copied().unwrap_or(0.0),
836                    status: row.get(21).map(|&v| v as u32 != 0).unwrap_or(true),
837                    loss_constant_mw: row.get(22).copied().unwrap_or(0.0),
838                    loss_linear: row.get(23).copied().unwrap_or(0.0),
839                    loss_quadratic_rectifier: row.get(24).copied().unwrap_or(0.0),
840                    loss_quadratic_inverter: row.get(25).copied().unwrap_or(0.0),
841                    droop: row.get(26).copied().unwrap_or(0.0),
842                    power_dc_setpoint_mw: row.get(27).copied().unwrap_or(0.0),
843                    voltage_dc_setpoint_pu: row.get(28).copied().unwrap_or(1.0),
844                    // Column 29 may be dVdcset (34-column format) or Pacmax (33-column).
845                    // Detect by checking total columns: >=34 → skip dVdcset at col 29.
846                    active_power_ac_max_mw: if row.len() >= 34 {
847                        row.get(30).copied().unwrap_or(f64::MAX)
848                    } else {
849                        row.get(29).copied().unwrap_or(f64::MAX)
850                    },
851                    active_power_ac_min_mw: if row.len() >= 34 {
852                        row.get(31).copied().unwrap_or(f64::MIN)
853                    } else {
854                        row.get(30).copied().unwrap_or(f64::MIN)
855                    },
856                    reactive_power_ac_max_mvar: if row.len() >= 34 {
857                        row.get(32).copied().unwrap_or(f64::MAX)
858                    } else {
859                        row.get(31).copied().unwrap_or(f64::MAX)
860                    },
861                    reactive_power_ac_min_mvar: if row.len() >= 34 {
862                        row.get(33).copied().unwrap_or(f64::MIN)
863                    } else {
864                        row.get(32).copied().unwrap_or(f64::MIN)
865                    },
866                }));
867        }
868    }
869
870    // Parse DC branch rows (9 columns: fbusdc, tbusdc, r, l, c, rateA, rateB, rateC, status)
871    for (row_idx, row) in dc_branch_rows.iter().enumerate() {
872        if row.len() < 6 {
873            return Err(MatpowerError::InsufficientColumns {
874                section: "branchdc".to_string(),
875                row: row_idx,
876                expected: 6,
877                got: row.len(),
878            });
879        }
880        if let Some(dc_grid) = network.hvdc.find_dc_grid_by_bus_mut(row[0] as u32) {
881            dc_grid.branches.push(DcBranch {
882                id: format!(
883                    "dc_grid_{}_branch_{}",
884                    dc_grid.id,
885                    dc_grid.branches.len() + 1
886                ),
887                from_bus: row[0] as u32,
888                to_bus: row[1] as u32,
889                r_ohm: row[2],
890                l_mh: row[3],
891                c_uf: row[4],
892                rating_a_mva: row[5],
893                rating_b_mva: row.get(6).copied().unwrap_or(0.0),
894                rating_c_mva: row.get(7).copied().unwrap_or(0.0),
895                status: row.get(8).map(|&v| v as u32 != 0).unwrap_or(true),
896            });
897        }
898    }
899    Ok(network)
900}
901
902/// Extract a string from a MATPOWER cell-array line.
903/// Input: `\t'Riversde  V2';` → output: `"Riversde  V2"` (trailing spaces trimmed).
904/// Returns an empty string if no quoted content is found.
905fn parse_cell_array_entry(line: &str) -> String {
906    let line = line.trim();
907    if let Some(start) = line.find('\'')
908        && let Some(end) = line[start + 1..].find('\'')
909    {
910        return line[start + 1..start + 1 + end].trim_end().to_string();
911    }
912    String::new()
913}
914
915fn matpower_technology_from_code(code: &str) -> GeneratorTechnology {
916    match code.trim().to_ascii_uppercase().as_str() {
917        "ST" => GeneratorTechnology::SteamTurbine,
918        "GT" | "JE" => GeneratorTechnology::CombustionTurbine,
919        "CC" => GeneratorTechnology::CombinedCycle,
920        "IC" => GeneratorTechnology::InternalCombustion,
921        "HY" | "HB" | "HR" => GeneratorTechnology::Hydro,
922        "PS" => GeneratorTechnology::PumpedStorage,
923        "HK" => GeneratorTechnology::Hydrokinetic,
924        "NB" | "NU" => GeneratorTechnology::Nuclear,
925        "GE" => GeneratorTechnology::Geothermal,
926        "WT" | "WS" | "W1" | "W2" | "W3" | "W4" => GeneratorTechnology::Wind,
927        "PV" => GeneratorTechnology::SolarPv,
928        "CP" => GeneratorTechnology::SolarThermal,
929        "BA" | "ES" => GeneratorTechnology::BatteryStorage,
930        "CE" => GeneratorTechnology::CompressedAirStorage,
931        "FW" => GeneratorTechnology::FlywheelStorage,
932        "FC" => GeneratorTechnology::FuelCell,
933        "SC" => GeneratorTechnology::SynchronousCondenser,
934        "SV" => GeneratorTechnology::StaticVarCompensator,
935        "DL" => GeneratorTechnology::DispatchableLoad,
936        "DC" => GeneratorTechnology::DcTie,
937        "OT" => GeneratorTechnology::Other,
938        _ => GeneratorTechnology::Other,
939    }
940}
941
942fn matpower_electrical_class_from_code(code: &str) -> Option<GenType> {
943    match code.trim().to_ascii_uppercase().as_str() {
944        "ST" | "GT" | "JE" | "CC" | "IC" | "HY" | "HB" | "HR" | "PS" | "NB" | "NU" | "GE"
945        | "SC" => Some(GenType::Synchronous),
946        "W1" | "W2" => Some(GenType::Asynchronous),
947        "PV" | "W3" | "W4" | "BA" | "ES" | "FW" | "FC" | "SV" => Some(GenType::InverterBased),
948        "DL" => Some(GenType::Hybrid),
949        "WT" | "WS" | "CP" | "CE" | "DC" | "OT" => Some(GenType::Unknown),
950        _ => None,
951    }
952}
953
954fn matpower_technology_from_fuel(fuel: &str) -> Option<GeneratorTechnology> {
955    match fuel.trim().to_ascii_lowercase().as_str() {
956        "wind" => Some(GeneratorTechnology::Wind),
957        "solar" => Some(GeneratorTechnology::SolarPv),
958        "hydro" => Some(GeneratorTechnology::Hydro),
959        "hydrops" => Some(GeneratorTechnology::PumpedStorage),
960        "nuclear" => Some(GeneratorTechnology::Nuclear),
961        "geothermal" => Some(GeneratorTechnology::Geothermal),
962        "battery" | "ess" => Some(GeneratorTechnology::BatteryStorage),
963        "dl" => Some(GeneratorTechnology::DispatchableLoad),
964        _ => None,
965    }
966}
967
968fn matpower_electrical_class_from_fuel(fuel: &str) -> Option<GenType> {
969    match fuel.trim().to_ascii_lowercase().as_str() {
970        "solar" | "battery" | "ess" => Some(GenType::InverterBased),
971        "hydro" | "hydrops" | "nuclear" | "coal" | "oil" | "gas" | "ng" => {
972            Some(GenType::Synchronous)
973        }
974        _ => None,
975    }
976}
977
978/// Check if a line starts a MATPOWER section like `mpc.bus = [`.
979/// Ensures we don't match `mpc.bus_name` when looking for `mpc.bus`.
980fn is_section_start(line: &str, section: &str) -> bool {
981    let pattern = format!("mpc.{section}");
982    if let Some(pos) = line.find(&pattern) {
983        let end = pos + pattern.len();
984        if end >= line.len() {
985            return false;
986        }
987        // Next character after the section name must not be alphanumeric or _
988        let next_byte = line.as_bytes()[end];
989        if next_byte.is_ascii_alphanumeric() || next_byte == b'_' {
990            return false;
991        }
992        // Must contain = and either [ or { (pglib-opf-hvdc uses curly braces)
993        let rest = &line[end..];
994        rest.contains('=') && (rest.contains('[') || rest.contains('{'))
995    } else {
996        false
997    }
998}
999
1000/// Try to parse data on the same line as a section header (after `[` or `{`).
1001fn try_parse_inline_data(line: &str, rows: &mut Vec<Vec<f64>>) {
1002    // Find either `[` or `{` as the opening delimiter.
1003    let bracket_pos = line.find('[').or_else(|| line.find('{'));
1004    if let Some(pos) = bracket_pos {
1005        let rest = &line[pos + 1..];
1006        let rest = rest
1007            .trim_end_matches(';')
1008            .trim_end_matches(']')
1009            .trim_end_matches('}')
1010            .trim();
1011        if !rest.is_empty()
1012            && let Some(row) = parse_numeric_row(rest)
1013        {
1014            rows.push(row);
1015        }
1016    }
1017}
1018
1019/// M-2: Validate that a parsed numeric field is finite (not NaN or Inf).
1020/// Called after Bus/Gen/Branch struct construction to catch corrupted data
1021/// that slipped through expression evaluation or other parse paths.
1022fn check_finite(
1023    val: f64,
1024    section: &'static str,
1025    row: usize,
1026    field: &'static str,
1027) -> Result<(), MatpowerError> {
1028    if !val.is_finite() {
1029        return Err(MatpowerError::NonFiniteValue {
1030            section,
1031            row,
1032            field,
1033            value: val,
1034        });
1035    }
1036    Ok(())
1037}
1038
1039/// MP-04: Parse a finite f64 value, rejecting NaN and Inf which propagate silently
1040/// into the Network struct and corrupt Y-bus construction.
1041/// Exception: "Inf" is allowed through as f64::INFINITY so that branch rate_a=Inf
1042/// (MATPOWER's convention for unconstrained) can be converted to 0.0 at the call site.
1043fn parse_finite_f64(s: &str) -> Option<f64> {
1044    let val: f64 = s.trim().parse().ok()?;
1045    // Reject NaN — it has no physical meaning and corrupts matrix arithmetic
1046    if val.is_nan() {
1047        return None;
1048    }
1049    // Allow Inf (unconstrained branch rating); caller must convert to 0.0 as needed
1050    Some(val)
1051}
1052
1053/// Parse a row of space/tab-separated numeric values.
1054/// Handles MATLAB expressions like `12/sqrt(3)`, `50/3`, and `1.33E-05`.
1055fn parse_numeric_row(s: &str) -> Option<Vec<f64>> {
1056    let s = s.trim();
1057    if s.is_empty() || s.starts_with(']') {
1058        return None;
1059    }
1060
1061    let values: Result<Vec<f64>, _> = s
1062        .split_whitespace()
1063        .filter(|t| !t.is_empty() && *t != ";")
1064        .map(|t| {
1065            let t = t.trim_end_matches(';');
1066            // MP-04: use parse_finite_f64 to reject NaN; Inf is allowed for rate_a=Inf
1067            parse_finite_f64(t)
1068                .or_else(|| eval_simple_expr(t))
1069                .ok_or(())
1070        })
1071        .collect();
1072
1073    match values {
1074        Ok(v) if !v.is_empty() => Some(v),
1075        _ => None,
1076    }
1077}
1078
1079/// Unit conversions needed for MATPOWER distribution cases.
1080///
1081/// Many MATPOWER distribution cases store loads in kW and branch impedances in Ohms,
1082/// with MATLAB code at the bottom to convert to MW and per-unit. Since we can't execute
1083/// MATLAB code, we detect these patterns and apply the conversions ourselves.
1084struct Conversions {
1085    /// Divide Pd, Qd by 1000 (kW → MW, kVAr → MVAr)
1086    kw_to_mw: bool,
1087    /// Convert branch R, X from Ohms to per-unit using Zbase = base_kv² / baseMVA
1088    ohms_to_pu: bool,
1089    /// Apply power factor conversion (kVA → MW + MVAr)
1090    power_factor: Option<f64>,
1091}
1092
1093/// Detect distribution case unit conversions from MATLAB code in the file.
1094fn detect_conversions(content: &str) -> Conversions {
1095    // MP-02: Only scan non-comment lines for conversion triggers. Without this filter,
1096    // a comment like `% Old code: mpc.bus(:, [PD, QD]) = ... / 1e3` would incorrectly
1097    // trigger the kW→MW conversion on all bus loads.
1098    let code_content: String = content
1099        .lines()
1100        .filter(|line| {
1101            let trimmed = line.trim();
1102            !trimmed.starts_with('%') && !trimmed.is_empty()
1103        })
1104        .collect::<Vec<_>>()
1105        .join("\n");
1106
1107    // Look for: mpc.bus(:, [PD, QD]) = mpc.bus(:, [PD, QD]) / 1e3
1108    let kw_to_mw = code_content.contains("/ 1e3") && code_content.contains("PD, QD");
1109
1110    // Look for: mpc.branch(:, [BR_R BR_X]) = ... / (Vbase^2 / Sbase)
1111    let ohms_to_pu =
1112        code_content.contains("Vbase^2 / Sbase") || code_content.contains("Vbase^2/Sbase");
1113
1114    // Look for power factor conversion: pf = 0.85 (or similar)
1115    let power_factor = if code_content.contains("* pf") || code_content.contains("*pf") {
1116        // Extract pf value from line like "pf = 0.85;"
1117        code_content
1118            .lines()
1119            .find(|l| {
1120                let t = l.trim();
1121                t.starts_with("pf ") || t.starts_with("pf=")
1122            })
1123            .and_then(|l| {
1124                l.find('=').and_then(|eq| {
1125                    l[eq + 1..]
1126                        .trim()
1127                        .trim_end_matches(';')
1128                        .trim()
1129                        .parse::<f64>()
1130                        .ok()
1131                })
1132            })
1133    } else {
1134        None
1135    };
1136
1137    Conversions {
1138        kw_to_mw,
1139        ohms_to_pu,
1140        power_factor,
1141    }
1142}
1143
1144/// Apply detected unit conversions to the parsed network.
1145///
1146/// `bus_pd_qd` carries the raw (bus_number, pd, qd) triples before Load creation.
1147/// Demand conversions (kW→MW, power factor) are applied to this vector, not
1148/// to the Bus struct which no longer carries demand fields.
1149fn apply_conversions(network: &mut Network, conv: &Conversions, bus_pd_qd: &mut [(u32, f64, f64)]) {
1150    if conv.kw_to_mw {
1151        for entry in bus_pd_qd.iter_mut() {
1152            entry.1 /= 1000.0;
1153            entry.2 /= 1000.0;
1154        }
1155    }
1156
1157    if conv.ohms_to_pu {
1158        // Build bus_num → base_kv lookup for per-branch impedance base
1159        let bus_kv: std::collections::HashMap<u32, f64> = network
1160            .buses
1161            .iter()
1162            .map(|b| (b.number, b.base_kv))
1163            .collect();
1164        let base_mva = network.base_mva;
1165        if base_mva > 0.0 {
1166            for branch in &mut network.branches {
1167                let base_kv = bus_kv.get(&branch.from_bus).copied().unwrap_or(1.0);
1168                if base_kv > 0.0 {
1169                    let z_base = base_kv * base_kv / base_mva;
1170                    branch.r /= z_base;
1171                    branch.x /= z_base;
1172                }
1173            }
1174        }
1175    }
1176
1177    if let Some(pf) = conv.power_factor {
1178        // After kW→MW conversion, Pd is in MVA (apparent power).
1179        // Convert: P_real = Pd * pf, Q = Pd * sin(acos(pf))
1180        let sin_phi = (1.0 - pf * pf).sqrt();
1181        for entry in bus_pd_qd.iter_mut() {
1182            let apparent = entry.1;
1183            entry.2 = apparent * sin_phi;
1184            entry.1 = apparent * pf;
1185        }
1186    }
1187}
1188
1189/// Evaluate a simple arithmetic expression (for MATPOWER values like "50/3", "12/sqrt(3)").
1190/// Supports: number literals, +, -, *, /, sqrt()
1191/// Operator precedence: +/- lowest, then */÷; left-to-right associativity.
1192fn eval_simple_expr(s: &str) -> Option<f64> {
1193    eval_expr_depth(s, 0)
1194}
1195
1196/// MP-03: Depth-limited recursive expression evaluator. Returns None when depth > 100
1197/// to prevent stack overflow from crafted inputs like 100k nested sqrt() calls.
1198fn eval_expr_depth(s: &str, depth: usize) -> Option<f64> {
1199    if depth > 100 {
1200        return None;
1201    }
1202    let s = s.trim();
1203    // Try direct parse first (fast path)
1204    if let Ok(v) = s.parse::<f64>() {
1205        return Some(v);
1206    }
1207    // Handle sqrt(x)
1208    if s.starts_with("sqrt(") && s.ends_with(')') {
1209        let inner = &s[5..s.len() - 1];
1210        return eval_expr_depth(inner, depth + 1).map(|v| v.sqrt());
1211    }
1212    let bytes = s.as_bytes();
1213    // Find the rightmost +/- (lowest precedence, skip first char to allow leading sign).
1214    // Rightmost split gives left-to-right associativity: a - b + c → (a - b) + c.
1215    for i in (1..bytes.len()).rev() {
1216        if bytes[i] == b'+' || bytes[i] == b'-' {
1217            let left = eval_expr_depth(&s[..i], depth + 1)?;
1218            let right = eval_expr_depth(&s[i + 1..], depth + 1)?;
1219            return Some(if bytes[i] == b'+' {
1220                left + right
1221            } else {
1222                left - right
1223            });
1224        }
1225    }
1226    // Find the rightmost */ (higher precedence than +/-).
1227    for i in (1..bytes.len()).rev() {
1228        if bytes[i] == b'*' || bytes[i] == b'/' {
1229            let left = eval_expr_depth(&s[..i], depth + 1)?;
1230            let right = eval_expr_depth(&s[i + 1..], depth + 1)?;
1231            return Some(if bytes[i] == b'*' {
1232                left * right
1233            } else {
1234                left / right
1235            });
1236        }
1237    }
1238    None
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use surge_network::network::{GenType, GeneratorTechnology};
1245
1246    #[allow(dead_code)]
1247    fn data_available() -> bool {
1248        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
1249            return std::path::Path::new(&p).exists();
1250        }
1251        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1252            .parent()
1253            .unwrap()
1254            .parent()
1255            .unwrap()
1256            .join("tests/data")
1257            .exists()
1258    }
1259    #[allow(dead_code)]
1260    fn test_data_dir() -> std::path::PathBuf {
1261        if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
1262            return std::path::PathBuf::from(p);
1263        }
1264        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1265            .parent()
1266            .unwrap()
1267            .parent()
1268            .unwrap()
1269            .join("tests/data")
1270    }
1271
1272    #[test]
1273    fn test_parse_case9() {
1274        if !data_available() {
1275            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1276            return;
1277        }
1278        let path = test_data_dir().join("case9.m");
1279        let net = parse_file(&path).expect("failed to parse case9");
1280
1281        assert_eq!(net.name, "case9");
1282        assert_eq!(net.base_mva, 100.0);
1283        assert_eq!(net.n_buses(), 9);
1284        assert_eq!(net.n_branches(), 9);
1285        assert_eq!(net.generators.len(), 3);
1286
1287        // Verify slack bus
1288        let slack = net.buses.iter().find(|b| b.is_slack()).unwrap();
1289        assert_eq!(slack.number, 1);
1290
1291        // Verify PV buses
1292        let pv_buses: Vec<u32> = net
1293            .buses
1294            .iter()
1295            .filter(|b| b.is_pv())
1296            .map(|b| b.number)
1297            .collect();
1298        assert_eq!(pv_buses, vec![2, 3]);
1299
1300        // Verify loads (via Load objects)
1301        let bus5_pd: f64 = net
1302            .loads
1303            .iter()
1304            .filter(|l| l.bus == 5)
1305            .map(|l| l.active_power_demand_mw)
1306            .sum();
1307        let bus5_qd: f64 = net
1308            .loads
1309            .iter()
1310            .filter(|l| l.bus == 5)
1311            .map(|l| l.reactive_power_demand_mvar)
1312            .sum();
1313        assert!((bus5_pd - 90.0).abs() < 1e-10);
1314        assert!((bus5_qd - 30.0).abs() < 1e-10);
1315
1316        let bus7_pd: f64 = net
1317            .loads
1318            .iter()
1319            .filter(|l| l.bus == 7)
1320            .map(|l| l.active_power_demand_mw)
1321            .sum();
1322        assert!((bus7_pd - 100.0).abs() < 1e-10);
1323
1324        let bus9_pd: f64 = net
1325            .loads
1326            .iter()
1327            .filter(|l| l.bus == 9)
1328            .map(|l| l.active_power_demand_mw)
1329            .sum();
1330        assert!((bus9_pd - 125.0).abs() < 1e-10);
1331
1332        // Verify generators
1333        let gen1 = &net.generators[0];
1334        assert_eq!(gen1.bus, 1);
1335        assert!((gen1.p - 72.3).abs() < 1e-10);
1336        assert!((gen1.voltage_setpoint_pu - 1.04).abs() < 1e-10);
1337        assert!(gen1.in_service);
1338
1339        // Verify branches
1340        let br0 = &net.branches[0];
1341        assert_eq!(br0.from_bus, 1);
1342        assert_eq!(br0.to_bus, 4);
1343        assert!((br0.r - 0.0).abs() < 1e-10);
1344        assert!((br0.x - 0.0576).abs() < 1e-10);
1345        assert!((br0.tap - 1.0).abs() < 1e-10); // ratio=0 → tap=1.0
1346        assert!(br0.in_service);
1347
1348        // Verify total generation and load
1349        assert!((net.total_generation_mw() - 320.3).abs() < 1e-10);
1350        assert!((net.total_load_mw() - 315.0).abs() < 1e-10);
1351    }
1352
1353    #[test]
1354    fn test_parse_case14() {
1355        if !data_available() {
1356            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1357            return;
1358        }
1359        let path = test_data_dir().join("case14.m");
1360        let net = parse_file(&path).expect("failed to parse case14");
1361
1362        assert_eq!(net.name, "case14");
1363        assert_eq!(net.base_mva, 100.0);
1364        assert_eq!(net.n_buses(), 14);
1365        assert_eq!(net.n_branches(), 20);
1366        assert_eq!(net.generators.len(), 5);
1367
1368        // Verify transformer taps (branches with non-zero ratio)
1369        let br_4_7 = net
1370            .branches
1371            .iter()
1372            .find(|b| b.from_bus == 4 && b.to_bus == 7)
1373            .unwrap();
1374        assert!((br_4_7.tap - 0.978).abs() < 1e-10);
1375
1376        let br_4_9 = net
1377            .branches
1378            .iter()
1379            .find(|b| b.from_bus == 4 && b.to_bus == 9)
1380            .unwrap();
1381        assert!((br_4_9.tap - 0.969).abs() < 1e-10);
1382
1383        // Verify bus with shunt susceptance (bus 9: Bs=19)
1384        let bus9 = net.buses.iter().find(|b| b.number == 9).unwrap();
1385        assert!((bus9.shunt_susceptance_mvar - 19.0).abs() < 1e-10);
1386
1387        // Verify baseKV — case14 HV buses (1-5, 8) at 132 kV, LV buses at 33 kV.
1388        // These values are set in the .m file (corrected from the original CDF conversion
1389        // which set baseKV = 0 for all buses).
1390        let bus1 = net.buses.iter().find(|b| b.number == 1).unwrap();
1391        assert!(
1392            (bus1.base_kv - 132.0).abs() < 1e-6,
1393            "bus 1 should be 132 kV HV"
1394        );
1395        let bus5 = net.buses.iter().find(|b| b.number == 5).unwrap();
1396        assert!(
1397            (bus5.base_kv - 132.0).abs() < 1e-6,
1398            "bus 5 should be 132 kV HV"
1399        );
1400        let bus6 = net.buses.iter().find(|b| b.number == 6).unwrap();
1401        assert!(
1402            (bus6.base_kv - 33.0).abs() < 1e-6,
1403            "bus 6 should be 33 kV LV"
1404        );
1405        let bus8 = net.buses.iter().find(|b| b.number == 8).unwrap();
1406        assert!(
1407            (bus8.base_kv - 132.0).abs() < 1e-6,
1408            "bus 8 should be 132 kV HV"
1409        );
1410        let bus9_kv = net.buses.iter().find(|b| b.number == 9).unwrap();
1411        assert!(
1412            (bus9_kv.base_kv - 33.0).abs() < 1e-6,
1413            "bus 9 should be 33 kV LV"
1414        );
1415        // All buses must have non-zero baseKV (fault analysis requirement)
1416        for bus in &net.buses {
1417            assert!(
1418                bus.base_kv > 0.0,
1419                "bus {} has zero baseKV — fault analysis will fail",
1420                bus.number
1421            );
1422        }
1423    }
1424
1425    #[test]
1426    fn test_parse_case30() {
1427        if !data_available() {
1428            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1429            return;
1430        }
1431        let path = test_data_dir().join("case30.m");
1432        let net = parse_file(&path).expect("failed to parse case30");
1433
1434        assert_eq!(net.n_buses(), 30);
1435        assert_eq!(net.generators.len(), 6);
1436    }
1437
1438    #[test]
1439    fn test_parse_case118() {
1440        if !data_available() {
1441            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1442            return;
1443        }
1444        let path = test_data_dir().join("case118.m");
1445        let net = parse_file(&path).expect("failed to parse case118");
1446
1447        assert_eq!(net.n_buses(), 118);
1448        assert_eq!(net.generators.len(), 54);
1449    }
1450
1451    #[test]
1452    fn test_parse_string_minimal() {
1453        if !data_available() {
1454            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1455            return;
1456        }
1457        let case = r#"
1458function mpc = testcase
1459mpc.version = '2';
1460mpc.baseMVA = 100;
1461mpc.bus = [
1462    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1463    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1464];
1465mpc.gen = [
1466    1   100  0   300  -300  1.0  100  1  250  10  0  0  0  0  0  0  0  0  0  0  0;
1467];
1468mpc.branch = [
1469    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1470];
1471"#;
1472        let net = parse_str(case).expect("failed to parse minimal case");
1473        assert_eq!(net.name, "testcase");
1474        assert_eq!(net.n_buses(), 2);
1475        assert_eq!(net.generators.len(), 1);
1476        assert_eq!(net.n_branches(), 1);
1477        let bus_pd = net.bus_load_p_mw();
1478        assert!((bus_pd[1] - 50.0).abs() < 1e-10);
1479    }
1480
1481    #[test]
1482    fn test_parse_generator_classification_sections() {
1483        let case = r#"
1484function mpc = testcase
1485mpc.version = '2';
1486mpc.baseMVA = 100;
1487mpc.bus = [
1488    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1489    2   2   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1490];
1491mpc.gen = [
1492    1   100  0   300  -300  1.0  100  1  250  10;
1493    2   40   0   100  -50   1.0  100  1  80   0;
1494];
1495mpc.branch = [
1496    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1497];
1498mpc.gentype = {
1499    'W2';
1500    'PV';
1501};
1502mpc.genfuel = {
1503    'wind';
1504    'solar';
1505};
1506"#;
1507        let net = parse_str(case).expect("failed to parse classification case");
1508        assert_eq!(net.generators.len(), 2);
1509        assert_eq!(
1510            net.generators[0].source_technology_code.as_deref(),
1511            Some("W2")
1512        );
1513        assert_eq!(
1514            net.generators[0].technology,
1515            Some(GeneratorTechnology::Wind)
1516        );
1517        assert_eq!(net.generators[0].gen_type, GenType::Asynchronous);
1518        assert_eq!(
1519            net.generators[0]
1520                .fuel
1521                .as_ref()
1522                .and_then(|fuel| fuel.fuel_type.as_deref()),
1523            Some("wind")
1524        );
1525        assert_eq!(
1526            net.generators[1].source_technology_code.as_deref(),
1527            Some("PV")
1528        );
1529        assert_eq!(
1530            net.generators[1].technology,
1531            Some(GeneratorTechnology::SolarPv)
1532        );
1533        assert_eq!(net.generators[1].gen_type, GenType::InverterBased);
1534    }
1535
1536    #[test]
1537    fn test_parse_gencost_case9() {
1538        if !data_available() {
1539            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1540            return;
1541        }
1542        let path = test_data_dir().join("case9.m");
1543        let net = parse_file(&path).expect("failed to parse case9");
1544
1545        // case9 has 3 generators with polynomial (type 2) cost curves
1546        assert_eq!(net.generators.len(), 3);
1547        for g in &net.generators {
1548            assert!(
1549                g.cost.is_some(),
1550                "generator at bus {} should have cost",
1551                g.bus
1552            );
1553        }
1554
1555        // Gen 1: 0.11*P^2 + 5*P + 150, startup=1500, shutdown=0
1556        let cost0 = net.generators[0].cost.as_ref().unwrap();
1557        match cost0 {
1558            CostCurve::Polynomial {
1559                startup,
1560                shutdown,
1561                coeffs,
1562            } => {
1563                assert!((startup - 1500.0).abs() < 1e-10);
1564                assert!((shutdown - 0.0).abs() < 1e-10);
1565                assert_eq!(coeffs.len(), 3);
1566                assert!((coeffs[0] - 0.11).abs() < 1e-10);
1567                assert!((coeffs[1] - 5.0).abs() < 1e-10);
1568                assert!((coeffs[2] - 150.0).abs() < 1e-10);
1569            }
1570            _ => panic!("expected polynomial cost"),
1571        }
1572
1573        // Gen 2: 0.085*P^2 + 1.2*P + 600, startup=2000
1574        let cost1 = net.generators[1].cost.as_ref().unwrap();
1575        match cost1 {
1576            CostCurve::Polynomial {
1577                startup, coeffs, ..
1578            } => {
1579                assert!((startup - 2000.0).abs() < 1e-10);
1580                assert_eq!(coeffs.len(), 3);
1581                assert!((coeffs[0] - 0.085).abs() < 1e-10);
1582                assert!((coeffs[1] - 1.2).abs() < 1e-10);
1583                assert!((coeffs[2] - 600.0).abs() < 1e-10);
1584            }
1585            _ => panic!("expected polynomial cost"),
1586        }
1587
1588        // Gen 3: 0.1225*P^2 + 1*P + 335, startup=3000
1589        let cost2 = net.generators[2].cost.as_ref().unwrap();
1590        match cost2 {
1591            CostCurve::Polynomial {
1592                startup, coeffs, ..
1593            } => {
1594                assert!((startup - 3000.0).abs() < 1e-10);
1595                assert_eq!(coeffs.len(), 3);
1596                assert!((coeffs[0] - 0.1225).abs() < 1e-10);
1597                assert!((coeffs[1] - 1.0).abs() < 1e-10);
1598                assert!((coeffs[2] - 335.0).abs() < 1e-10);
1599            }
1600            _ => panic!("expected polynomial cost"),
1601        }
1602    }
1603
1604    #[test]
1605    fn test_parse_gencost_rejects_invalid_generator_rows() {
1606        let case = r#"
1607function mpc = testcase
1608mpc.baseMVA = 100;
1609mpc.bus = [
1610    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1611    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1612];
1613mpc.gen = [
1614    99  10  0   200  -100  1.0  100  1  200  0;
1615    2   40  0   150  -75   1.0  100  1  150  0;
1616];
1617mpc.branch = [
1618    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1619];
1620mpc.gencost = [
1621    2   0   0   3   1   2   3;
1622    2   0   0   3   4   5   6;
1623];
1624"#;
1625        let err = parse_str(case).expect_err("invalid generator row should be rejected");
1626        assert!(
1627            matches!(err, MatpowerError::Parse { .. }),
1628            "unexpected error: {err:?}"
1629        );
1630    }
1631
1632    #[test]
1633    fn test_parse_gencost_case118() {
1634        if !data_available() {
1635            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1636            return;
1637        }
1638        let path = test_data_dir().join("case118.m");
1639        let net = parse_file(&path).expect("failed to parse case118");
1640
1641        // case118 has 54 generators, all should have cost data
1642        let with_cost = net.generators.iter().filter(|g| g.cost.is_some()).count();
1643        assert_eq!(with_cost, 54, "all 54 generators should have cost data");
1644    }
1645
1646    #[test]
1647    fn test_parse_no_gencost() {
1648        if !data_available() {
1649            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1650            return;
1651        }
1652        // Network without gencost section — generators should have cost = None
1653        let case = r#"
1654function mpc = testcase
1655mpc.baseMVA = 100;
1656mpc.bus = [
1657    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1658    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1659];
1660mpc.gen = [
1661    1   100  0   300  -300  1.0  100  1  250  10;
1662];
1663mpc.branch = [
1664    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1665];
1666"#;
1667        let net = parse_str(case).expect("failed to parse");
1668        assert!(net.generators[0].cost.is_none());
1669    }
1670
1671    #[test]
1672    fn test_parse_gencost_piecewise_linear() {
1673        if !data_available() {
1674            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1675            return;
1676        }
1677        let case = r#"
1678function mpc = testcase
1679mpc.baseMVA = 100;
1680mpc.bus = [
1681    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1682    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1683];
1684mpc.gen = [
1685    1   100  0   300  -300  1.0  100  1  250  10;
1686];
1687mpc.branch = [
1688    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1689];
1690mpc.gencost = [
1691    1  0  0  3  0  0  100  1000  200  3000;
1692];
1693"#;
1694        let net = parse_str(case).expect("failed to parse");
1695        let cost = net.generators[0].cost.as_ref().unwrap();
1696        match cost {
1697            CostCurve::PiecewiseLinear { points, .. } => {
1698                assert_eq!(points.len(), 3);
1699                assert!((points[0].0 - 0.0).abs() < 1e-10);
1700                assert!((points[0].1 - 0.0).abs() < 1e-10);
1701                assert!((points[1].0 - 100.0).abs() < 1e-10);
1702                assert!((points[1].1 - 1000.0).abs() < 1e-10);
1703                assert!((points[2].0 - 200.0).abs() < 1e-10);
1704                assert!((points[2].1 - 3000.0).abs() < 1e-10);
1705            }
1706            _ => panic!("expected piecewise-linear cost"),
1707        }
1708    }
1709
1710    #[test]
1711    fn test_parse_ramp_rates_rts_gmlc() {
1712        if !data_available() {
1713            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1714            return;
1715        }
1716        let path = test_data_dir().join("case_RTS_GMLC.m");
1717        let net = parse_file(&path).expect("failed to parse RTS-GMLC");
1718
1719        // RTS-GMLC has 21-column gen data with nonzero ramp rates in cols 16-18
1720        // Raw file values: ramp_agc=3 (MW/min), ramp_10=3 (MW/10-min), ramp_30=3 (MW/30-min)
1721        // After normalization to MW/min:
1722        //   reg_ramp_up_curve = [(0.0, 3.0)] (from RAMP_AGC, already in MW/min)
1723        //   ramp_up_curve     = [(0.0, 0.3)] (from RAMP_10: 3/10 = 0.3 MW/min)
1724        let g0 = &net.generators[0];
1725        assert_eq!(g0.ramp_agc_mw_per_min(), Some(3.0));
1726        assert!(
1727            (g0.ramp_up_mw_per_min().unwrap() - 0.3).abs() < 1e-10,
1728            "ramp_up={:?}",
1729            g0.ramp_up_mw_per_min()
1730        );
1731
1732        // Gen 2: bus 101, raw file: ramp_agc=2, ramp_10=2, ramp_30=2
1733        // Normalized: reg_ramp_up_curve=[(0,2.0)], ramp_up_curve=[(0,0.2)]
1734        let g2 = &net.generators[2];
1735        assert_eq!(g2.ramp_agc_mw_per_min(), Some(2.0));
1736        assert!(
1737            (g2.ramp_up_mw_per_min().unwrap() - 0.2).abs() < 1e-10,
1738            "ramp_up={:?}",
1739            g2.ramp_up_mw_per_min()
1740        );
1741    }
1742
1743    #[test]
1744    fn test_parse_ramp_rates_case9_none() {
1745        if !data_available() {
1746            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1747            return;
1748        }
1749        // case9 has only 10 gen columns — ramp rates should be None
1750        let path = test_data_dir().join("case9.m");
1751        let net = parse_file(&path).expect("failed to parse case9");
1752        for g in &net.generators {
1753            assert!(
1754                g.ramp_up_mw_per_min().is_none(),
1755                "case9 should have no ramp_up"
1756            );
1757            assert!(
1758                g.ramp_agc_mw_per_min().is_none(),
1759                "case9 should have no ramp_agc"
1760            );
1761        }
1762    }
1763
1764    #[test]
1765    fn test_parse_bus_name_case14() {
1766        if !data_available() {
1767            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1768            return;
1769        }
1770        let path = test_data_dir().join("case14.m");
1771        let net = parse_file(&path).expect("failed to parse case14");
1772
1773        assert_eq!(net.n_buses(), 14);
1774        // mpc.bus_name entries are positional — bus[0] corresponds to bus number 1
1775        assert_eq!(net.buses[0].name, "Bus 1     HV");
1776        assert_eq!(net.buses[5].name, "Bus 6     LV");
1777        assert_eq!(net.buses[13].name, "Bus 14    LV");
1778    }
1779
1780    #[test]
1781    fn test_parse_bus_name_case118() {
1782        if !data_available() {
1783            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1784            return;
1785        }
1786        let path = test_data_dir().join("case118.m");
1787        let net = parse_file(&path).expect("failed to parse case118");
1788
1789        assert_eq!(net.n_buses(), 118);
1790        // First few buses should have real substation names
1791        assert_eq!(net.buses[0].name, "Riversde  V2");
1792        assert_eq!(net.buses[1].name, "Pokagon   V2");
1793        assert_eq!(net.buses[4].name, "Olive     V2");
1794        // All buses should have non-empty names
1795        for bus in &net.buses {
1796            assert!(
1797                !bus.name.is_empty(),
1798                "bus {} should have a name",
1799                bus.number
1800            );
1801        }
1802    }
1803
1804    #[test]
1805    fn test_parse_bus_name_inline() {
1806        if !data_available() {
1807            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1808            return;
1809        }
1810        let case = r#"
1811function mpc = testcase
1812mpc.baseMVA = 100;
1813mpc.bus = [
1814    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1815    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1816    3   2   30  10  0   0   1   1.0   0   345   1   1.1   0.9;
1817];
1818mpc.gen = [
1819    1   100  0   300  -300  1.0  100  1  250  10;
1820];
1821mpc.branch = [
1822    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1823    2   3   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1824];
1825mpc.bus_name = {
1826    'SUBSTATION A';
1827    'SUBSTATION B';
1828    'SUBSTATION C';
1829};
1830"#;
1831        let net = parse_str(case).expect("failed to parse");
1832        assert_eq!(net.buses[0].name, "SUBSTATION A");
1833        assert_eq!(net.buses[1].name, "SUBSTATION B");
1834        assert_eq!(net.buses[2].name, "SUBSTATION C");
1835    }
1836
1837    #[test]
1838    fn test_parse_bus_name_activsg2000() {
1839        if !data_available() {
1840            eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
1841            return;
1842        }
1843        let path = test_data_dir().join("case_ACTIVSg2000.m");
1844        let net = parse_file(&path).expect("failed to parse ACTIVSg2000");
1845
1846        assert_eq!(net.n_buses(), 2000);
1847        // First bus should be ODESSA
1848        assert_eq!(net.buses[0].name, "ODESSA 2 0");
1849        assert_eq!(net.buses[1].name, "PRESIDIO 2 0");
1850        // All buses should have names
1851        let unnamed = net.buses.iter().filter(|b| b.name.is_empty()).count();
1852        assert_eq!(
1853            unnamed, 0,
1854            "all 2000 buses should have names from mpc.bus_name"
1855        );
1856    }
1857
1858    #[test]
1859    fn test_bus_angles_converted_to_radians() {
1860        let case = r#"
1861function mpc = test
1862mpc.baseMVA = 100;
1863mpc.bus = [
1864    1   3   0   0   0   0   1   1.0   45.0   345   1   1.1   0.9;
1865];
1866mpc.gen = [
1867    1   0  0   300  -300  1.0  100  1  250  10  0  0  0  0  0  0  0  0  0  0  0;
1868];
1869mpc.branch = [
1870    1   1   0.01  0.1  0  100  100  100  0  0  1  -360  360;
1871];
1872"#;
1873        let net = parse_str(case).expect("failed to parse");
1874        let expected_radians = 45.0_f64.to_radians();
1875        assert!((net.buses[0].voltage_angle_rad - expected_radians).abs() < 1e-10);
1876    }
1877
1878    #[test]
1879    fn test_branch_angmin_angmax_converted_to_radians() {
1880        let case = r#"
1881function mpc = test
1882mpc.baseMVA = 100;
1883mpc.bus = [
1884    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1885    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1886];
1887mpc.gen = [
1888    1   100  0   300  -300  1.0  100  1  250  10;
1889];
1890mpc.branch = [
1891    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -30  30;
1892];
1893"#;
1894        let net = parse_str(case).expect("failed to parse");
1895        let br = &net.branches[0];
1896        let expected_min = (-30.0_f64).to_radians();
1897        let expected_max = 30.0_f64.to_radians();
1898        assert!(
1899            (br.angle_diff_min_rad.unwrap() - expected_min).abs() < 1e-12,
1900            "angmin should be converted to radians: got {}, expected {}",
1901            br.angle_diff_min_rad.unwrap(),
1902            expected_min
1903        );
1904        assert!(
1905            (br.angle_diff_max_rad.unwrap() - expected_max).abs() < 1e-12,
1906            "angmax should be converted to radians: got {}, expected {}",
1907            br.angle_diff_max_rad.unwrap(),
1908            expected_max
1909        );
1910    }
1911
1912    #[test]
1913    fn test_branch_angmin_angmax_default_360_converted() {
1914        // When MATPOWER specifies -360/360 degrees, verify proper radian conversion
1915        let case = r#"
1916function mpc = test
1917mpc.baseMVA = 100;
1918mpc.bus = [
1919    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1920    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1921];
1922mpc.gen = [
1923    1   100  0   300  -300  1.0  100  1  250  10;
1924];
1925mpc.branch = [
1926    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1927];
1928"#;
1929        let net = parse_str(case).expect("failed to parse");
1930        let br = &net.branches[0];
1931        let expected_min = (-360.0_f64).to_radians(); // -2*pi
1932        let expected_max = 360.0_f64.to_radians(); // +2*pi
1933        assert!(
1934            (br.angle_diff_min_rad.unwrap() - expected_min).abs() < 1e-12,
1935            "angmin -360 deg -> -2*pi rad"
1936        );
1937        assert!(
1938            (br.angle_diff_max_rad.unwrap() - expected_max).abs() < 1e-12,
1939            "angmax +360 deg -> +2*pi rad"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_branch_angmin_angmax_none_when_missing() {
1945        // When MATPOWER data has only 11 columns (no angmin/angmax), fields should be None
1946        let case = r#"
1947function mpc = test
1948mpc.baseMVA = 100;
1949mpc.bus = [
1950    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1951    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1952];
1953mpc.gen = [
1954    1   100  0   300  -300  1.0  100  1  250  10;
1955];
1956mpc.branch = [
1957    1   2   0.01  0.1  0.02  100  100  100  0  0  1;
1958];
1959"#;
1960        let net = parse_str(case).expect("failed to parse");
1961        let br = &net.branches[0];
1962        assert!(
1963            br.angle_diff_min_rad.is_none(),
1964            "angmin should be None when column absent"
1965        );
1966        assert!(
1967            br.angle_diff_max_rad.is_none(),
1968            "angmax should be None when column absent"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_parse_dc_busdc() {
1974        let content = r#"
1975function mpc = case_acdc
1976mpc.version = '2';
1977mpc.baseMVA = 100;
1978mpc.bus = [
1979    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
1980    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
1981];
1982mpc.gen = [
1983    1   100  0   300  -300  1.0  100  1  250  10;
1984];
1985mpc.branch = [
1986    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
1987];
1988mpc.busdc = [
1989    1   1   0   1.0   345   1.1   0.9;
1990    2   1   0   1.0   345   1.1   0.9;
1991];
1992mpc.convdc = [
1993    1   1   1   1   0   0   0   1.0   0.01  0.01  1  1.0  0.01  1  0.01  0.01  1  345  1.1  0.9  1.1  1;
1994    2   2   2   1   0   0   0   1.0   0.01  0.01  1  1.0  0.01  1  0.01  0.01  1  345  1.1  0.9  1.1  1;
1995];
1996mpc.branchdc = [
1997    1   2   0.052   0   0   100   0   0   1;
1998];
1999"#;
2000        let net = parse_str(content).expect("Should parse DC network format");
2001        let dc_buses: Vec<_> = net.hvdc.dc_buses().collect();
2002        let dc_converters: Vec<_> = net
2003            .hvdc
2004            .dc_converters()
2005            .filter_map(|c| c.as_vsc())
2006            .collect();
2007        let dc_branches: Vec<_> = net.hvdc.dc_branches().collect();
2008        assert_eq!(dc_buses.len(), 2);
2009        assert_eq!(dc_converters.len(), 2);
2010        assert_eq!(dc_branches.len(), 1);
2011        assert_eq!(net.hvdc.dc_grids.len(), 1);
2012
2013        // Verify DC bus fields
2014        assert_eq!(dc_buses[0].bus_id, 1);
2015        assert!((dc_buses[0].v_dc_pu - 1.0).abs() < 1e-10);
2016
2017        // Verify DC converter fields
2018        assert_eq!(dc_converters[0].dc_bus, 1);
2019        assert_eq!(dc_converters[0].ac_bus, 1);
2020        assert_eq!(dc_converters[0].control_type_dc, 1);
2021        assert_eq!(dc_converters[1].control_type_dc, 2);
2022
2023        // Verify DC branch fields
2024        assert_eq!(dc_branches[0].from_bus, 1);
2025        assert_eq!(dc_branches[0].to_bus, 2);
2026        assert!((dc_branches[0].r_ohm - 0.052).abs() < 1e-10);
2027    }
2028
2029    #[test]
2030    fn test_parse_dc_alternate_keys() {
2031        // pglib uses mpc.dcbus, mpc.dcconv, mpc.dcbranch
2032        let content = r#"
2033function mpc = case_pglib
2034mpc.version = '2';
2035mpc.baseMVA = 100;
2036mpc.bus = [
2037    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
2038    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
2039];
2040mpc.gen = [
2041    1   100  0   300  -300  1.0  100  1  250  10;
2042];
2043mpc.branch = [
2044    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
2045];
2046mpc.dcbus = [
2047    1   1   0   1.0   345   1.1   0.9;
2048];
2049mpc.dcconv = [
2050    1   1   1   1   0   0   0   1.0   0.01  0.01  1  1.0  0.01  1  0.01  0.01  1  345  1.1  0.9  1.1  1;
2051];
2052mpc.dcbranch = [
2053    1   1   0.1   0   0   100   0   0   1;
2054];
2055"#;
2056        let net = parse_str(content).expect("Should parse pglib alternate keys");
2057        assert_eq!(net.hvdc.dc_bus_count(), 1);
2058        assert_eq!(net.hvdc.dc_converter_count(), 1);
2059        assert_eq!(net.hvdc.dc_branch_count(), 1);
2060    }
2061
2062    #[test]
2063    fn test_parse_dc_converter_loss_params() {
2064        let content = r#"
2065function mpc = case_loss
2066mpc.version = '2';
2067mpc.baseMVA = 100;
2068mpc.bus = [
2069    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
2070    2   1   50  20  0   0   1   1.0   0   345   1   1.1   0.9;
2071];
2072mpc.gen = [
2073    1   100  0   300  -300  1.0  100  1  250  10;
2074];
2075mpc.branch = [
2076    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
2077];
2078mpc.busdc = [
2079    1   1   0   1.0   345   1.1   0.9;
2080];
2081mpc.convdc = [
2082    1   1   1   1   50   10   0   1.0   0.01  0.05  1  1.01  0.02  1  0.005  0.08  1  345  1.1  0.9  1.5  1   1.103  0.887  2.885  4.371  0.005  50.0  1.0  100  -100  50  -50;
2083];
2084mpc.branchdc = [
2085    1   1   0.1   0   0   100   0   0   1;
2086];
2087"#;
2088        let net = parse_str(content).expect("Should parse converter loss params");
2089        let dc_converters: Vec<_> = net
2090            .hvdc
2091            .dc_converters()
2092            .filter_map(|c| c.as_vsc())
2093            .collect();
2094        let conv = dc_converters[0];
2095        assert!((conv.loss_constant_mw - 1.103).abs() < 1e-10);
2096        assert!((conv.loss_linear - 0.887).abs() < 1e-10);
2097        assert!((conv.loss_quadratic_rectifier - 2.885).abs() < 1e-10);
2098        assert!((conv.loss_quadratic_inverter - 4.371).abs() < 1e-10);
2099        assert!((conv.droop - 0.005).abs() < 1e-10);
2100        assert!((conv.power_dc_setpoint_mw - 50.0).abs() < 1e-10);
2101        assert!((conv.active_power_mw - 50.0).abs() < 1e-10);
2102        assert!((conv.reactive_power_mvar - 10.0).abs() < 1e-10);
2103    }
2104
2105    #[test]
2106    fn test_parse_dc_curly_brace_syntax() {
2107        // pglib-opf-hvdc nem_2000bus uses curly braces `{...}` instead of `[...]`
2108        let content = r#"
2109function mpc = case_curly
2110mpc.version = '2';
2111mpc.baseMVA = 100;
2112mpc.bus = [
2113    1   3   0   0   0   0   1   1.0   0   500   1   1.1   0.9;
2114    2   1   50  20  0   0   1   1.0   0   500   1   1.1   0.9;
2115];
2116mpc.gen = [
2117    1   100  0   300  -300  1.0  100  1  250  10;
2118];
2119mpc.branch = [
2120    1   2   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
2121];
2122mpc.busdc = {
2123    1   1   0   1.0   400   1.1   0.9   0;
2124    2   1   0   1.0   400   1.1   0.9   0;
2125};
2126mpc.convdc = {
2127    1   1   1   1   50  10  0   1   0.00086  0.03  1  1  0.01  0  0.0005  0.015  1  500  1.1  0.9  500.0  1  0.5517  3.031  0.0175  0.0  0.005  -58.6  1.008  0  500  -500  500  -500;
2128    2   2   2   1  -50 -10  0   1   0.00086  0.03  1  1  0.01  0  0.0005  0.015  1  500  1.1  0.9  500.0  1  0.5517  3.031  0.0175  0.0  0.007   21.9  1.000  0  500  -500  500  -500;
2129};
2130mpc.branchdc = {
2131    1   2   0.001   0   0   510   500   500   1;
2132};
2133"#;
2134        let net = parse_str(content).expect("Should parse curly-brace DC network sections");
2135        let dc_buses: Vec<_> = net.hvdc.dc_buses().collect();
2136        let dc_converters: Vec<_> = net
2137            .hvdc
2138            .dc_converters()
2139            .filter_map(|c| c.as_vsc())
2140            .collect();
2141        let dc_branches: Vec<_> = net.hvdc.dc_branches().collect();
2142        assert_eq!(dc_buses.len(), 2);
2143        assert_eq!(dc_buses[0].bus_id, 1);
2144        assert!((dc_buses[0].base_kv_dc - 400.0).abs() < 1e-10);
2145        assert_eq!(dc_converters.len(), 2);
2146        assert_eq!(dc_converters[0].dc_bus, 1);
2147        assert_eq!(dc_converters[0].ac_bus, 1);
2148        assert!((dc_converters[0].loss_constant_mw - 0.5517).abs() < 1e-10);
2149        assert!((dc_converters[0].loss_linear - 3.031).abs() < 1e-10);
2150        // 34-column format: dVdcset at col 29, then Pacmax at col 30
2151        assert!((dc_converters[0].active_power_ac_max_mw - 500.0).abs() < 1e-10);
2152        assert!((dc_converters[0].active_power_ac_min_mw - (-500.0)).abs() < 1e-10);
2153        assert_eq!(dc_branches.len(), 1);
2154        assert!((dc_branches[0].r_ohm - 0.001).abs() < 1e-10);
2155    }
2156
2157    #[test]
2158    fn test_parse_dc_no_dc_sections() {
2159        // Standard MATPOWER file without DC sections should still work
2160        let content = r#"
2161function mpc = case_plain
2162mpc.version = '2';
2163mpc.baseMVA = 100;
2164mpc.bus = [
2165    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
2166];
2167mpc.gen = [
2168    1   100  0   300  -300  1.0  100  1  250  10;
2169];
2170mpc.branch = [
2171    1   1   0.01  0.1  0.02  100  100  100  0  0  1  -360  360;
2172];
2173"#;
2174        let net = parse_str(content).expect("Standard MATPOWER should still parse");
2175        assert_eq!(net.hvdc.dc_bus_count(), 0);
2176        assert_eq!(net.hvdc.dc_converter_count(), 0);
2177        assert_eq!(net.hvdc.dc_branch_count(), 0);
2178    }
2179
2180    /// Parser converts MATPOWER pc1/pc2 two-point capability curve to pq_curve.
2181    ///
2182    /// MATPOWER gen columns (0-indexed):
2183    ///   0:bus 1:pg 2:qg 3:qmax 4:qmin 5:vs 6:mbase 7:status 8:pmax 9:pmin
2184    ///   10:pc1 11:pc2 12:qc1min 13:qc1max 14:qc2min 15:qc2max
2185    /// pq_curve stores (p_pu, qmax_pu, qmin_pu) in ascending P order.
2186    #[test]
2187    fn test_pq_curve_from_pc_fields() {
2188        // Test 1: pc1 == pc2 → degenerate → empty pq_curve.
2189        // Gen row columns: bus pg qg qmax qmin vs mbase status pmax pmin pc1 pc2 qc1min qc1max qc2min qc2max
2190        //                   1  100  0  200 -100  1.0  100    1  200    0  100  100    -30     80    -20     60
2191        let case_equal = r#"
2192function mpc = test_equal_pc
2193mpc.baseMVA = 100;
2194mpc.bus = [
2195    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
2196    2   1   80  30  0   0   1   1.0   0   345   1   1.1   0.9;
2197];
2198mpc.gen = [
2199    1   100  0   200  -100  1.0  100  1  200  0   100  100  -30  80  -20  60;
2200];
2201mpc.branch = [
2202    1   2   0.01  0.1  0.02  300  300  300  0  0  1;
2203];
2204mpc.gencost = [
2205    2   0   0   3   0   1   0;
2206];
2207"#;
2208        let net = parse_str(case_equal).expect("failed to parse equal-pc case");
2209        assert!(
2210            net.generators[0]
2211                .reactive_capability
2212                .as_ref()
2213                .is_none_or(|r| r.pq_curve.is_empty()),
2214            "equal pc1=pc2=100 → degenerate → empty pq_curve"
2215        );
2216
2217        // Test 2: distinct pc1=50, pc2=200 MW → 2-point curve.
2218        // Col 10=pc1=50, col 11=pc2=200, col 12=qc1min=-50, col 13=qc1max=150,
2219        // col 14=qc2min=-20, col 15=qc2max=80.
2220        // pq_curve = [(50/100, 150/100, -50/100), (200/100, 80/100, -20/100)]
2221        //          = [(0.5, 1.5, -0.5), (2.0, 0.8, -0.2)]
2222        let case_curve = r#"
2223function mpc = test_dcurve
2224mpc.baseMVA = 100;
2225mpc.bus = [
2226    1   3   0   0   0   0   1   1.0   0   345   1   1.1   0.9;
2227    2   1   80  30  0   0   1   1.0   0   345   1   1.1   0.9;
2228];
2229mpc.gen = [
2230    1   150  0   200  -100  1.0  100  1  200  0   50  200  -50  150  -20  80;
2231];
2232mpc.branch = [
2233    1   2   0.01  0.1  0.02  300  300  300  0  0  1;
2234];
2235mpc.gencost = [
2236    2   0   0   3   0   1   0;
2237];
2238"#;
2239        let net2 = parse_str(case_curve).expect("failed to parse D-curve case");
2240        let g = &net2.generators[0];
2241        let pq_curve = &g
2242            .reactive_capability
2243            .as_ref()
2244            .expect("should have reactive_capability")
2245            .pq_curve;
2246        assert_eq!(
2247            pq_curve.len(),
2248            2,
2249            "two distinct pc breakpoints → 2 pq_curve points"
2250        );
2251
2252        let (p1_pu, qmax1_pu, qmin1_pu) = pq_curve[0];
2253        let (p2_pu, qmax2_pu, qmin2_pu) = pq_curve[1];
2254
2255        assert!(
2256            (p1_pu - 0.5).abs() < 1e-10,
2257            "p1 should be 0.5 pu, got {p1_pu}"
2258        );
2259        assert!(
2260            (qmax1_pu - 1.5).abs() < 1e-10,
2261            "qmax1 should be 1.5 pu, got {qmax1_pu}"
2262        );
2263        assert!(
2264            (qmin1_pu - (-0.5)).abs() < 1e-10,
2265            "qmin1 should be -0.5 pu, got {qmin1_pu}"
2266        );
2267
2268        assert!(
2269            (p2_pu - 2.0).abs() < 1e-10,
2270            "p2 should be 2.0 pu, got {p2_pu}"
2271        );
2272        assert!(
2273            (qmax2_pu - 0.8).abs() < 1e-10,
2274            "qmax2 should be 0.8 pu, got {qmax2_pu}"
2275        );
2276        assert!(
2277            (qmin2_pu - (-0.2)).abs() < 1e-10,
2278            "qmin2 should be -0.2 pu, got {qmin2_pu}"
2279        );
2280    }
2281}