1use 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
23pub 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
30pub fn to_string(network: &Network) -> Result<String, MatpowerWriteError> {
32 let mut out = String::with_capacity(64 * 1024);
33
34 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 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 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 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 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 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, inj.reactive_power_injection_mvar, inj.active_power_injection_mw, inj.active_power_injection_mw, )?;
131 }
132 writeln!(out, "];")?;
133 writeln!(out)?;
134
135 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 let tap_out = if (br.tap - 1.0).abs() < 1e-9 {
145 0.0
146 } else {
147 br.tap
148 };
149 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 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 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 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 if network.hvdc.has_explicit_dc_topology() {
275 writeln!(out)?;
276
277 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 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 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
394fn 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
449fn 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 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 let net = simple_network();
544 let s = to_string(&net).unwrap();
545 assert!(s.contains("mpc.branch"));
547 }
548
549 #[test]
550 fn test_va_in_degrees() {
551 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; net.buses.push(bus);
557 net.generators.push(Generator::new(1, 0.0, 1.0));
558 let s = to_string(&net).unwrap();
559 assert!(
561 !s.contains("0.5235"),
562 "va written in radians, expected degrees"
563 );
564 let va_deg_str = format!("{}", (std::f64::consts::PI / 6.0_f64).to_degrees());
566 assert!(
567 s.contains(&va_deg_str[..4]), "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 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 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 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 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}