Skip to main content

surge_io/matpower/
writer.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! MATPOWER (.m) case file writer.
3//!
4//! Writes the MATPOWER v2 format (mpc.bus, mpc.gen, mpc.branch, mpc.gencost).
5//! Suitable for round-trip with MATPOWER, pypowsybl, and PowerModels.jl.
6
7use std::fmt::Write as FmtWrite;
8use std::path::Path;
9
10use surge_network::Network;
11use surge_network::market::CostCurve;
12use surge_network::network::{BusType, Generator, GeneratorTechnology};
13use thiserror::Error;
14
15#[derive(Error, Debug)]
16pub enum MatpowerWriteError {
17    #[error("I/O error: {0}")]
18    Io(#[from] std::io::Error),
19    #[error("format error: {0}")]
20    Fmt(#[from] std::fmt::Error),
21}
22
23/// Write a Network to a MATPOWER .m file on disk.
24pub fn write_file(network: &Network, path: &Path) -> Result<(), MatpowerWriteError> {
25    let content = to_string(network)?;
26    std::fs::write(path, content)?;
27    Ok(())
28}
29
30/// Serialize a Network to a MATPOWER .m string.
31pub fn to_string(network: &Network) -> Result<String, MatpowerWriteError> {
32    let mut out = String::with_capacity(64 * 1024);
33
34    // Header
35    let fn_name = sanitize_name(&network.name);
36    writeln!(out, "function mpc = {fn_name}")?;
37    writeln!(
38        out,
39        "% Exported by Surge (https://github.com/amptimal/surge)"
40    )?;
41    writeln!(out, "mpc.version = '2';")?;
42    writeln!(out, "mpc.baseMVA = {};", network.base_mva)?;
43    writeln!(out)?;
44
45    // Precompute per-bus demand from Load objects only (do not subtract
46    // PowerInjections — those are emitted as generator rows to preserve them
47    // across MATPOWER roundtrips).
48    let bus_map = network.bus_index_map();
49    let mut bus_demand_p = vec![0.0; network.buses.len()];
50    let mut bus_demand_q = vec![0.0; network.buses.len()];
51    for load in &network.loads {
52        if load.in_service {
53            if let Some(&idx) = bus_map.get(&load.bus) {
54                bus_demand_p[idx] += load.active_power_demand_mw;
55                bus_demand_q[idx] += load.reactive_power_demand_mvar;
56            }
57        }
58    }
59
60    // --- Bus data ---
61    writeln!(out, "mpc.bus = [")?;
62    writeln!(
63        out,
64        "%\tbus_i\ttype\tPd\tQd\tGs\tBs\tarea\tVm\tVa\tbaseKV\tzone\tVmax\tVmin"
65    )?;
66    for (bi, bus) in network.buses.iter().enumerate() {
67        let bus_type_code = match bus.bus_type {
68            BusType::PQ => 1,
69            BusType::PV => 2,
70            BusType::Slack => 3,
71            BusType::Isolated => 4,
72        };
73        let va_deg = bus.voltage_angle_rad.to_degrees();
74        writeln!(
75            out,
76            "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
77            bus.number,
78            bus_type_code,
79            bus_demand_p.get(bi).copied().unwrap_or(0.0),
80            bus_demand_q.get(bi).copied().unwrap_or(0.0),
81            bus.shunt_conductance_mw,
82            bus.shunt_susceptance_mvar,
83            bus.area,
84            bus.voltage_magnitude_pu,
85            va_deg,
86            bus.base_kv,
87            bus.zone,
88            bus.voltage_max_pu,
89            bus.voltage_min_pu
90        )?;
91    }
92    writeln!(out, "];")?;
93    writeln!(out)?;
94
95    // --- Generator data ---
96    writeln!(out, "mpc.gen = [")?;
97    writeln!(
98        out,
99        "%\tbus\tPg\tQg\tQmax\tQmin\tVg\tmBase\tstatus\tPmax\tPmin"
100    )?;
101    for g in &network.generators {
102        let status = if g.in_service { 1 } else { 0 };
103        // Clamp non-finite and f64::MAX/MIN sentinel values for MATPOWER compatibility
104        let qmax = clamp_for_matpower(g.qmax, 9999.0);
105        let qmin = clamp_for_matpower(g.qmin, -9999.0);
106        let pmax = clamp_for_matpower(g.pmax, 9999.0);
107        let pmin = clamp_for_matpower(g.pmin, -9999.0);
108        let mbase = clamp_for_matpower(g.machine_base_mva, 100.0);
109        writeln!(
110            out,
111            "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
112            g.bus, g.p, g.q, qmax, qmin, g.voltage_setpoint_pu, mbase, status, pmax, pmin
113        )?;
114    }
115    // Emit PowerInjections as fixed generators so they survive MATPOWER roundtrips.
116    for inj in &network.power_injections {
117        if !inj.in_service {
118            continue;
119        }
120        writeln!(
121            out,
122            "\t{}\t{}\t{}\t{}\t{}\t1.0\t100.0\t1\t{}\t{};",
123            inj.bus,
124            inj.active_power_injection_mw,
125            inj.reactive_power_injection_mvar,
126            inj.reactive_power_injection_mvar, // qmax = q
127            inj.reactive_power_injection_mvar, // qmin = q
128            inj.active_power_injection_mw,     // pmax = p
129            inj.active_power_injection_mw,     // pmin = p
130        )?;
131    }
132    writeln!(out, "];")?;
133    writeln!(out)?;
134
135    // --- Branch data ---
136    writeln!(out, "mpc.branch = [")?;
137    writeln!(
138        out,
139        "%\tfbus\ttbus\tr\tx\tb\trateA\trateB\trateC\ttap\tshift\tstatus\tangmin\tangmax"
140    )?;
141    for br in &network.branches {
142        let status = if br.in_service { 1 } else { 0 };
143        // MATPOWER convention: tap==0 means 1.0 (line); write 1.0 taps as 0
144        let tap_out = if (br.tap - 1.0).abs() < 1e-9 {
145            0.0
146        } else {
147            br.tap
148        };
149        // angmin/angmax stored internally in radians; MATPOWER expects degrees
150        let angmin = br
151            .angle_diff_min_rad
152            .unwrap_or(-2.0 * std::f64::consts::PI)
153            .to_degrees();
154        let angmax = br
155            .angle_diff_max_rad
156            .unwrap_or(2.0 * std::f64::consts::PI)
157            .to_degrees();
158        // Clamp non-finite ratings to 0 (MATPOWER convention for unconstrained)
159        let ra = clamp_for_matpower(br.rating_a_mva, 0.0);
160        let rb = clamp_for_matpower(br.rating_b_mva, 0.0);
161        let rc = clamp_for_matpower(br.rating_c_mva, 0.0);
162        writeln!(
163            out,
164            "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
165            br.from_bus,
166            br.to_bus,
167            br.r,
168            br.x,
169            br.b,
170            ra,
171            rb,
172            rc,
173            tap_out,
174            br.phase_shift_rad.to_degrees(),
175            status,
176            angmin,
177            angmax
178        )?;
179    }
180    writeln!(out, "];")?;
181    writeln!(out)?;
182
183    // --- Gencost data (if any generator has cost) ---
184    let has_cost = network.generators.iter().any(|g| g.cost.is_some());
185    if has_cost {
186        writeln!(out, "mpc.gencost = [")?;
187        writeln!(out, "%\tmodel\tstartup\tshutdown\tn\tcost parameters")?;
188        for g in &network.generators {
189            match &g.cost {
190                Some(CostCurve::Polynomial {
191                    startup,
192                    shutdown,
193                    coeffs,
194                }) => {
195                    let n = coeffs.len();
196                    write!(out, "\t2\t{startup}\t{shutdown}\t{n}")?;
197                    for c in coeffs {
198                        write!(out, "\t{c}")?;
199                    }
200                    writeln!(out, ";")?;
201                }
202                Some(CostCurve::PiecewiseLinear {
203                    startup,
204                    shutdown,
205                    points,
206                }) => {
207                    let n = points.len();
208                    write!(out, "\t1\t{startup}\t{shutdown}\t{n}")?;
209                    for (p, c) in points {
210                        write!(out, "\t{p}\t{c}")?;
211                    }
212                    writeln!(out, ";")?;
213                }
214                None => {
215                    // Default: linear cost at $0/MWh
216                    writeln!(out, "\t2\t0\t0\t2\t0\t0;")?;
217                }
218            }
219        }
220        for inj in &network.power_injections {
221            if !inj.in_service {
222                continue;
223            }
224            writeln!(out, "\t2\t0\t0\t1\t0;")?;
225        }
226        writeln!(out, "];")?;
227    }
228
229    let has_gentype = network
230        .generators
231        .iter()
232        .any(|g| g.source_technology_code.is_some() || g.technology.is_some());
233    if has_gentype {
234        writeln!(out)?;
235        writeln!(out, "mpc.gentype = {{")?;
236        for g in &network.generators {
237            let code = matpower_gentype_for_generator(g);
238            writeln!(out, "\t'{}';", escape_matpower_string(&code))?;
239        }
240        for inj in &network.power_injections {
241            if inj.in_service {
242                writeln!(out, "\t'OT';")?;
243            }
244        }
245        writeln!(out, "}};")?;
246    }
247
248    let has_genfuel = network.generators.iter().any(|g| {
249        g.fuel
250            .as_ref()
251            .and_then(|f| f.fuel_type.as_deref())
252            .is_some()
253    });
254    if has_genfuel {
255        writeln!(out)?;
256        writeln!(out, "mpc.genfuel = {{")?;
257        for g in &network.generators {
258            let fuel = g
259                .fuel
260                .as_ref()
261                .and_then(|f| f.fuel_type.as_deref())
262                .unwrap_or("other");
263            writeln!(out, "\t'{}';", escape_matpower_string(fuel))?;
264        }
265        for inj in &network.power_injections {
266            if inj.in_service {
267                writeln!(out, "\t'other';")?;
268            }
269        }
270        writeln!(out, "}};")?;
271    }
272
273    // --- DC network sections (only when DC buses exist) ---
274    if network.hvdc.has_explicit_dc_topology() {
275        writeln!(out)?;
276
277        // mpc.busdc — 8 columns
278        writeln!(out, "mpc.busdc = [")?;
279        writeln!(
280            out,
281            "%\tbusdc_i\tgrid\tPdc\tVdc\tbasekVdc\tVdcmax\tVdcmin\tCdc"
282        )?;
283        for dc_grid in &network.hvdc.dc_grids {
284            for b in &dc_grid.buses {
285                writeln!(
286                    out,
287                    "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
288                    b.bus_id,
289                    dc_grid.id,
290                    b.p_dc_mw,
291                    b.v_dc_pu,
292                    b.base_kv_dc,
293                    b.v_dc_max,
294                    b.v_dc_min,
295                    b.cost
296                )?;
297            }
298        }
299        writeln!(out, "];")?;
300        writeln!(out)?;
301
302        // mpc.convdc — 33 columns (no dVdcset)
303        writeln!(out, "mpc.convdc = [")?;
304        writeln!(
305            out,
306            "%\tdc_bus\tac_bus\ttype_dc\ttype_ac\tP_g\tQ_g\tislcc\tVtar\trtf\txtf\ttransformer\ttm\tbf\tfilter\trc\txc\treactor\tbasekVac\tVmmax\tVmmin\tImax\tstatus\tLossA\tLossB\tLossCrec\tLossCinv\tdroop\tPdcset\tVdcset\tPacmax\tPacmin\tQacmax\tQacmin"
307        )?;
308        for dc_grid in &network.hvdc.dc_grids {
309            for converter in &dc_grid.converters {
310                let Some(c) = converter.as_vsc() else {
311                    continue;
312                };
313                let is_lcc = if c.is_lcc { 1 } else { 0 };
314                let transformer = if c.transformer { 1 } else { 0 };
315                let filter = if c.filter { 1 } else { 0 };
316                let reactor = if c.reactor { 1 } else { 0 };
317                let status = if c.status { 1 } else { 0 };
318                let pac_max = clamp_for_matpower(c.active_power_ac_max_mw, 9999.0);
319                let pac_min = clamp_for_matpower(c.active_power_ac_min_mw, -9999.0);
320                let qac_max = clamp_for_matpower(c.reactive_power_ac_max_mvar, 9999.0);
321                let qac_min = clamp_for_matpower(c.reactive_power_ac_min_mvar, -9999.0);
322                writeln!(
323                    out,
324                    "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
325                    c.dc_bus,
326                    c.ac_bus,
327                    c.control_type_dc,
328                    c.control_type_ac,
329                    c.active_power_mw,
330                    c.reactive_power_mvar,
331                    is_lcc,
332                    c.voltage_setpoint_pu,
333                    c.transformer_r_pu,
334                    c.transformer_x_pu,
335                    transformer,
336                    c.tap_ratio,
337                    c.filter_susceptance_pu,
338                    filter,
339                    c.reactor_r_pu,
340                    c.reactor_x_pu,
341                    reactor,
342                    c.base_kv_ac,
343                    c.voltage_max_pu,
344                    c.voltage_min_pu,
345                    c.current_max_pu,
346                    status,
347                    c.loss_constant_mw,
348                    c.loss_linear,
349                    c.loss_quadratic_rectifier,
350                    c.loss_quadratic_inverter,
351                    c.droop,
352                    c.power_dc_setpoint_mw,
353                    c.voltage_dc_setpoint_pu,
354                    pac_max,
355                    pac_min,
356                    qac_max,
357                    qac_min
358                )?;
359            }
360        }
361        writeln!(out, "];")?;
362        writeln!(out)?;
363
364        // mpc.branchdc — 9 columns
365        writeln!(out, "mpc.branchdc = [")?;
366        writeln!(
367            out,
368            "%\tfbusdc\ttbusdc\tr\tl\tc\trateA\trateB\trateC\tstatus"
369        )?;
370        for dc_grid in &network.hvdc.dc_grids {
371            for br in &dc_grid.branches {
372                let status = if br.status { 1 } else { 0 };
373                writeln!(
374                    out,
375                    "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{};",
376                    br.from_bus,
377                    br.to_bus,
378                    br.r_ohm,
379                    br.l_mh,
380                    br.c_uf,
381                    br.rating_a_mva,
382                    br.rating_b_mva,
383                    br.rating_c_mva,
384                    status
385                )?;
386            }
387        }
388        writeln!(out, "];")?;
389    }
390
391    Ok(out)
392}
393
394/// Clamp a value to a finite fallback if it is non-finite (±Inf, NaN) or at the
395/// f64::MAX/f64::MIN sentinel used internally for "unlimited".
396///
397/// MATPOWER uses `Inf` in MATLAB, but many downstream tools (pypowsybl, PowerModels.jl)
398/// cannot parse `Inf` reliably.  Using 9999 or 0 as the sentinel is safer.
399fn clamp_for_matpower(v: f64, fallback: f64) -> f64 {
400    if !v.is_finite() || v >= f64::MAX / 2.0 || v <= f64::MIN / 2.0 {
401        fallback
402    } else {
403        v
404    }
405}
406
407fn matpower_gentype_for_generator(generator: &Generator) -> String {
408    if let Some(raw) = &generator.source_technology_code
409        && !raw.trim().is_empty()
410    {
411        return raw.clone();
412    }
413    match generator.technology {
414        Some(GeneratorTechnology::SteamTurbine) => "ST",
415        Some(GeneratorTechnology::CombustionTurbine) => "GT",
416        Some(GeneratorTechnology::CombinedCycle) => "CC",
417        Some(GeneratorTechnology::InternalCombustion) => "IC",
418        Some(GeneratorTechnology::Hydro) => "HY",
419        Some(GeneratorTechnology::PumpedStorage) => "PS",
420        Some(GeneratorTechnology::Hydrokinetic) => "HK",
421        Some(GeneratorTechnology::Nuclear) => "NU",
422        Some(GeneratorTechnology::Geothermal) => "GE",
423        Some(GeneratorTechnology::Wind) => "WT",
424        Some(GeneratorTechnology::SolarPv) => "PV",
425        Some(GeneratorTechnology::SolarThermal) => "CP",
426        Some(GeneratorTechnology::BatteryStorage) => "BA",
427        Some(GeneratorTechnology::CompressedAirStorage) => "CE",
428        Some(GeneratorTechnology::FlywheelStorage) => "FW",
429        Some(GeneratorTechnology::FuelCell) => "FC",
430        Some(GeneratorTechnology::SynchronousCondenser) => "SC",
431        Some(GeneratorTechnology::StaticVarCompensator) => "SV",
432        Some(GeneratorTechnology::DispatchableLoad) => "DL",
433        Some(GeneratorTechnology::DcTie) => "DC",
434        Some(GeneratorTechnology::Thermal)
435        | Some(GeneratorTechnology::Solar)
436        | Some(GeneratorTechnology::Wave)
437        | Some(GeneratorTechnology::Storage)
438        | Some(GeneratorTechnology::Motor)
439        | Some(GeneratorTechnology::Other)
440        | None => "OT",
441    }
442    .to_string()
443}
444
445fn escape_matpower_string(value: &str) -> String {
446    value.replace('\'', "''")
447}
448
449/// Sanitize a string to be a valid MATLAB function name.
450fn sanitize_name(name: &str) -> String {
451    let s: String = name
452        .chars()
453        .map(|c| {
454            if c.is_alphanumeric() || c == '_' {
455                c
456            } else {
457                '_'
458            }
459        })
460        .collect();
461    // Must not start with digit
462    if s.starts_with(|c: char| c.is_ascii_digit()) {
463        format!("case_{s}")
464    } else if s.is_empty() {
465        "case_unknown".to_string()
466    } else {
467        s
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use surge_network::Network;
475    use surge_network::market::CostCurve;
476    use surge_network::network::{
477        Branch, Bus, BusType, DcBranch, DcBus, DcConverterStation, Generator, GeneratorTechnology,
478        Load,
479    };
480
481    fn simple_network() -> Network {
482        let mut net = Network::new("test_case");
483        net.base_mva = 100.0;
484
485        let mut slack = Bus::new(1, BusType::Slack, 345.0);
486        slack.voltage_magnitude_pu = 1.04;
487        slack.voltage_angle_rad = 0.0;
488        net.buses.push(slack);
489
490        let pq = Bus::new(2, BusType::PQ, 345.0);
491        net.buses.push(pq);
492        net.loads.push(Load::new(2, 100.0, 35.0));
493
494        let mut g = Generator::new(1, 80.0, 1.04);
495        g.qmax = 300.0;
496        g.qmin = -300.0;
497        g.pmax = 250.0;
498        g.pmin = 10.0;
499        g.cost = Some(CostCurve::Polynomial {
500            startup: 1500.0,
501            shutdown: 0.0,
502            coeffs: vec![0.11, 5.0, 150.0],
503        });
504        net.generators.push(g);
505
506        net.branches
507            .push(Branch::new_line(1, 2, 0.01938, 0.05917, 0.0528));
508        net
509    }
510
511    #[test]
512    fn test_write_produces_matpower_sections() {
513        let net = simple_network();
514        let s = to_string(&net).unwrap();
515        assert!(s.contains("function mpc = test_case"));
516        assert!(s.contains("mpc.baseMVA = 100"));
517        assert!(s.contains("mpc.bus = ["));
518        assert!(s.contains("mpc.gen = ["));
519        assert!(s.contains("mpc.branch = ["));
520        assert!(s.contains("mpc.gencost = ["));
521    }
522
523    #[test]
524    fn test_roundtrip_matpower() {
525        use crate::matpower::parse_str;
526        let net = simple_network();
527        let s = to_string(&net).unwrap();
528        let net2 = parse_str(&s).expect("round-trip parse failed");
529        assert_eq!(net2.n_buses(), net.n_buses());
530        assert_eq!(net2.n_branches(), net.n_branches());
531        assert!((net2.base_mva - net.base_mva).abs() < 1e-6);
532        assert!(
533            (net2.buses[0].voltage_magnitude_pu - net.buses[0].voltage_magnitude_pu).abs() < 1e-6
534        );
535        let pd1 = net.bus_load_p_mw();
536        let pd2 = net2.bus_load_p_mw();
537        assert!((pd2[1] - pd1[1]).abs() < 1e-6);
538    }
539
540    #[test]
541    fn test_tap_convention() {
542        // tap == 1.0 should be written as 0 (MATPOWER line convention)
543        let net = simple_network();
544        let s = to_string(&net).unwrap();
545        // Branch is a line (tap=1.0 internally), should appear as 0 in file
546        assert!(s.contains("mpc.branch"));
547    }
548
549    #[test]
550    fn test_va_in_degrees() {
551        // va is stored in radians, must be written in degrees
552        let mut net = Network::new("va_test");
553        net.base_mva = 100.0;
554        let mut bus = Bus::new(1, BusType::Slack, 138.0);
555        bus.voltage_angle_rad = std::f64::consts::PI / 6.0; // 30 degrees
556        net.buses.push(bus);
557        net.generators.push(Generator::new(1, 0.0, 1.0));
558        let s = to_string(&net).unwrap();
559        // Should not contain raw radians value (~0.5235...)
560        assert!(
561            !s.contains("0.5235"),
562            "va written in radians, expected degrees"
563        );
564        // The degree value should be ~30: check it appears (float format may vary)
565        let va_deg_str = format!("{}", (std::f64::consts::PI / 6.0_f64).to_degrees());
566        assert!(
567            s.contains(&va_deg_str[..4]), // first 4 chars: "29.9" or "30.0"
568            "expected va in degrees (~30) in output, got: {s}"
569        );
570    }
571
572    #[test]
573    fn test_piecewise_linear_gencost() {
574        let mut net = Network::new("pwl_case");
575        net.base_mva = 100.0;
576        net.buses.push(Bus::new(1, BusType::Slack, 138.0));
577        let mut g = Generator::new(1, 0.0, 1.0);
578        g.cost = Some(CostCurve::PiecewiseLinear {
579            startup: 0.0,
580            shutdown: 0.0,
581            points: vec![(0.0, 0.0), (100.0, 1000.0), (200.0, 3000.0)],
582        });
583        net.generators.push(g);
584        let s = to_string(&net).unwrap();
585        // Type 1 = piecewise-linear
586        assert!(s.contains("\t1\t"));
587    }
588
589    #[test]
590    fn test_file_roundtrip() {
591        let net = simple_network();
592        let tmp = std::env::temp_dir().join("surge_mw_writer_test.m");
593        write_file(&net, &tmp).unwrap();
594        let content = std::fs::read_to_string(&tmp).unwrap();
595        assert!(content.contains("mpc.bus = ["));
596        let _ = std::fs::remove_file(&tmp);
597    }
598
599    #[test]
600    fn test_power_injection_rows_get_matching_gencost_entries() {
601        let mut net = simple_network();
602        net.power_injections
603            .push(surge_network::network::PowerInjection::new(2, 15.0, 3.0));
604
605        let s = to_string(&net).unwrap();
606        let gen_section = s
607            .split("mpc.gen = [")
608            .nth(1)
609            .and_then(|part| part.split("];").next())
610            .unwrap();
611        let gencost_section = s
612            .split("mpc.gencost = [")
613            .nth(1)
614            .and_then(|part| part.split("];").next())
615            .unwrap();
616        let gen_count = gen_section
617            .lines()
618            .filter(|line| line.starts_with('\t') && line.trim_end().ends_with(';'))
619            .count();
620        let gencost_count = gencost_section
621            .lines()
622            .filter(|line| line.starts_with('\t') && line.trim_end().ends_with(';'))
623            .count();
624
625        assert_eq!(gen_count, 2);
626        assert_eq!(gencost_count, 2);
627    }
628
629    #[test]
630    fn test_generator_classification_sections_written() {
631        let mut net = simple_network();
632        let generator = net
633            .generators
634            .first_mut()
635            .expect("simple network should have one generator");
636        generator.technology = Some(GeneratorTechnology::SolarPv);
637        generator.source_technology_code = Some("PV".to_string());
638        generator
639            .fuel
640            .get_or_insert_with(Default::default)
641            .fuel_type = Some("solar".to_string());
642
643        let s = to_string(&net).expect("writer should succeed");
644        assert!(s.contains("mpc.gentype = {"));
645        assert!(s.contains("\t'PV';"));
646        assert!(s.contains("mpc.genfuel = {"));
647        assert!(s.contains("\t'solar';"));
648    }
649
650    fn dc_network() -> Network {
651        let mut net = simple_network();
652        let grid = net.hvdc.ensure_dc_grid(1, None);
653        grid.buses.push(DcBus {
654            bus_id: 1,
655            p_dc_mw: 0.0,
656            v_dc_pu: 1.0,
657            base_kv_dc: 400.0,
658            v_dc_max: 1.1,
659            v_dc_min: 0.9,
660            cost: 0.0,
661            g_shunt_siemens: 0.0,
662            r_ground_ohm: 0.0,
663        });
664        grid.buses.push(DcBus {
665            bus_id: 2,
666            p_dc_mw: 0.0,
667            v_dc_pu: 1.0,
668            base_kv_dc: 400.0,
669            v_dc_max: 1.1,
670            v_dc_min: 0.9,
671            cost: 0.0,
672            g_shunt_siemens: 0.0,
673            r_ground_ohm: 0.0,
674        });
675        grid.converters.push(
676            DcConverterStation {
677                id: String::new(),
678                dc_bus: 1,
679                ac_bus: 1,
680                control_type_dc: 2,
681                control_type_ac: 1,
682                active_power_mw: 200.0,
683                reactive_power_mvar: 30.0,
684                is_lcc: false,
685                voltage_setpoint_pu: 1.0,
686                transformer_r_pu: 0.0015,
687                transformer_x_pu: 0.1121,
688                transformer: true,
689                tap_ratio: 1.0,
690                filter_susceptance_pu: 0.0887,
691                filter: true,
692                reactor_r_pu: 0.0001,
693                reactor_x_pu: 0.16428,
694                reactor: true,
695                base_kv_ac: 345.0,
696                voltage_max_pu: 1.1,
697                voltage_min_pu: 0.9,
698                current_max_pu: 1.1,
699                status: true,
700                loss_constant_mw: 1.103,
701                loss_linear: 0.887,
702                loss_quadratic_rectifier: 2.885,
703                loss_quadratic_inverter: 2.885,
704                droop: 0.0,
705                power_dc_setpoint_mw: 200.0,
706                voltage_dc_setpoint_pu: 1.0,
707                active_power_ac_max_mw: 500.0,
708                active_power_ac_min_mw: -500.0,
709                reactive_power_ac_max_mvar: 300.0,
710                reactive_power_ac_min_mvar: -300.0,
711            }
712            .into(),
713        );
714        grid.converters.push(
715            DcConverterStation {
716                id: String::new(),
717                dc_bus: 2,
718                ac_bus: 2,
719                control_type_dc: 1,
720                control_type_ac: 1,
721                active_power_mw: -195.0,
722                reactive_power_mvar: 20.0,
723                is_lcc: false,
724                voltage_setpoint_pu: 1.0,
725                transformer_r_pu: 0.0015,
726                transformer_x_pu: 0.1121,
727                transformer: true,
728                tap_ratio: 1.0,
729                filter_susceptance_pu: 0.0887,
730                filter: true,
731                reactor_r_pu: 0.0001,
732                reactor_x_pu: 0.16428,
733                reactor: true,
734                base_kv_ac: 345.0,
735                voltage_max_pu: 1.1,
736                voltage_min_pu: 0.9,
737                current_max_pu: 1.1,
738                status: true,
739                loss_constant_mw: 1.103,
740                loss_linear: 0.887,
741                loss_quadratic_rectifier: 2.885,
742                loss_quadratic_inverter: 2.885,
743                droop: 0.0,
744                power_dc_setpoint_mw: -195.0,
745                voltage_dc_setpoint_pu: 1.0,
746                active_power_ac_max_mw: 500.0,
747                active_power_ac_min_mw: -500.0,
748                reactive_power_ac_max_mvar: 300.0,
749                reactive_power_ac_min_mvar: -300.0,
750            }
751            .into(),
752        );
753        grid.branches.push(DcBranch {
754            id: "dc_branch_1".into(),
755            from_bus: 1,
756            to_bus: 2,
757            r_ohm: 5.0,
758            l_mh: 25.0,
759            c_uf: 12.0,
760            rating_a_mva: 500.0,
761            rating_b_mva: 0.0,
762            rating_c_mva: 0.0,
763            status: true,
764        });
765        net
766    }
767
768    #[test]
769    fn test_dc_sections_written() {
770        let net = dc_network();
771        let s = to_string(&net).unwrap();
772        assert!(s.contains("mpc.busdc = ["));
773        assert!(s.contains("mpc.convdc = ["));
774        assert!(s.contains("mpc.branchdc = ["));
775    }
776
777    #[test]
778    fn test_dc_roundtrip() {
779        use crate::matpower::parse_str;
780        let net = dc_network();
781        let s = to_string(&net).unwrap();
782        let net2 = parse_str(&s).expect("DC round-trip parse failed");
783        let dc_buses: Vec<_> = net2.hvdc.dc_buses().collect();
784        let dc_converters: Vec<_> = net2
785            .hvdc
786            .dc_converters()
787            .filter_map(|c| c.as_vsc())
788            .collect();
789        let dc_branches: Vec<_> = net2.hvdc.dc_branches().collect();
790        // DC buses
791        assert_eq!(dc_buses.len(), 2);
792        assert_eq!(dc_buses[0].bus_id, 1);
793        assert!((dc_buses[0].base_kv_dc - 400.0).abs() < 1e-6);
794        // DC converters
795        assert_eq!(dc_converters.len(), 2);
796        assert_eq!(dc_converters[0].control_type_dc, 2);
797        assert!((dc_converters[0].active_power_mw - 200.0).abs() < 1e-6);
798        assert!((dc_converters[0].loss_constant_mw - 1.103).abs() < 1e-6);
799        assert!((dc_converters[0].loss_linear - 0.887).abs() < 1e-6);
800        assert!((dc_converters[0].loss_quadratic_rectifier - 2.885).abs() < 1e-6);
801        assert!((dc_converters[0].active_power_ac_max_mw - 500.0).abs() < 1e-6);
802        assert!((dc_converters[0].active_power_ac_min_mw - (-500.0)).abs() < 1e-6);
803        assert!((dc_converters[0].voltage_dc_setpoint_pu - 1.0).abs() < 1e-6);
804        assert!(dc_converters[0].transformer);
805        assert!(dc_converters[0].filter);
806        assert!(dc_converters[0].reactor);
807        // DC branches
808        assert_eq!(dc_branches.len(), 1);
809        assert!((dc_branches[0].r_ohm - 5.0).abs() < 1e-6);
810        assert!((dc_branches[0].l_mh - 25.0).abs() < 1e-6);
811        assert!((dc_branches[0].c_uf - 12.0).abs() < 1e-6);
812        assert!(dc_branches[0].status);
813    }
814
815    #[test]
816    fn test_no_dc_sections_when_empty() {
817        let net = simple_network();
818        let s = to_string(&net).unwrap();
819        assert!(!s.contains("mpc.busdc"));
820        assert!(!s.contains("mpc.convdc"));
821        assert!(!s.contains("mpc.branchdc"));
822    }
823}