1use std::collections::{BTreeMap, BTreeSet};
19use std::fmt::Write as _;
20use std::sync::Arc;
21
22use serde_json::Value;
23
24use super::{
25 Conversion, branch_rating_set_drop_warning, jnum, sanitize_quoted,
26 warn_extra_branch_rating_sets,
27};
28use crate::network::{
29 Area, BalancedNetwork, Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras,
30 Generator, Hvdc, Impedance, Load, LoadVoltageModel, Shunt, ShuntBlock, SolverParams,
31 SourceFormat, SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
32 TransformerControlMode, Winding,
33};
34use crate::{Error, Result};
35
36const FMT: &str = "PSS/E .raw";
37const REV: u32 = 33;
38const PSSE_EXTRA_BRANCH_RATINGS: usize = 9;
39
40fn psse_extra_rating_name(slot: usize) -> String {
41 format!("RATE{}", slot + 4)
42}
43
44fn psse_extra_rating_slot(name: &str) -> Option<usize> {
45 let upper = name.trim().to_ascii_uppercase();
46 let suffix = upper
47 .strip_prefix("RATE")
48 .or_else(|| upper.strip_prefix("RATING"))?
49 .trim_start_matches([' ', '_']);
50 let n = suffix.parse::<usize>().ok()?;
51 (4..=12).contains(&n).then_some(n - 4)
52}
53
54fn read_extra_branch_ratings(
55 fields: &[String],
56 rating_start: usize,
57 named_record: bool,
58) -> Result<Vec<BranchRatingSet>> {
59 if !named_record {
60 return Ok(Vec::new());
61 }
62 let mut ratings = Vec::new();
63 for slot in 0..PSSE_EXTRA_BRANCH_RATINGS {
64 let rate_mva = num_at(fields, rating_start + 3 + slot, 0.0)?;
65 if rate_mva.abs() > f64::EPSILON {
66 ratings.push(BranchRatingSet::new(psse_extra_rating_name(slot), rate_mva));
67 }
68 }
69 Ok(ratings)
70}
71
72fn psse_extra_rating_values(
73 branch: &Branch,
74 branch_index: usize,
75 warnings: &mut Vec<String>,
76) -> [f64; PSSE_EXTRA_BRANCH_RATINGS] {
77 let mut values = [0.0; PSSE_EXTRA_BRANCH_RATINGS];
78 let mut used = [false; PSSE_EXTRA_BRANCH_RATINGS];
79 let mut deferred = Vec::new();
80
81 for rating in &branch.rating_sets {
82 if let Some(slot) = psse_extra_rating_slot(&rating.name) {
83 if !used[slot] {
84 values[slot] = rating.rate_mva;
85 used[slot] = true;
86 continue;
87 }
88 }
89 deferred.push(rating);
90 }
91
92 for rating in deferred {
93 if let Some(slot) = used.iter().position(|is_used| !*is_used) {
94 values[slot] = rating.rate_mva;
95 used[slot] = true;
96 warnings.push(branch_rating_set_rename_warning(
97 branch_index,
98 branch,
99 rating,
100 &psse_extra_rating_name(slot),
101 ));
102 } else {
103 warnings.push(branch_rating_set_drop_warning(
104 "PSS/E v34/v35",
105 branch_index,
106 branch,
107 rating,
108 ));
109 }
110 }
111
112 values
113}
114
115fn branch_rating_set_rename_warning(
116 branch_index: usize,
117 branch: &Branch,
118 rating: &BranchRatingSet,
119 emitted_name: &str,
120) -> String {
121 format!(
122 "branch {} ({} to {}) rating set {}={} MVA emitted as {} in PSS/E v34/v35; rating set names outside RATE4-RATE12 are not preserved",
123 branch_index + 1,
124 branch.from,
125 branch.to,
126 rating.name,
127 rating.rate_mva,
128 emitted_name
129 )
130}
131
132fn warn_psse_extra_branch_ratings_dropped(net: &BalancedNetwork, warnings: &mut Vec<String>) {
133 warn_extra_branch_rating_sets("PSS/E v33", net, warnings);
134}
135
136const NAME_FORBIDDEN: &[char] = &['\'', '/'];
140
141#[must_use]
145pub fn write_psse(net: &BalancedNetwork) -> Conversion {
146 write_psse_rev(net, REV)
147}
148
149#[must_use]
161#[expect(clippy::too_many_lines)]
164pub fn write_psse_rev(net: &BalancedNetwork, rev: u32) -> Conversion {
165 let modern = rev >= 34;
168 let mut warnings = Vec::new();
169 let mut nonfinite = false;
170 let mut sanitized_quoted = 0usize;
171 let mut s = String::new();
172 let mut num = |x: f64| -> String {
175 if x.is_finite() {
176 let s = format!("{x}");
177 if s.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
182 format!("{s}.0")
183 } else {
184 s
185 }
186 } else {
187 nonfinite = true;
188 let sentinel = if x > 0.0 {
189 1.0e10
190 } else if x < 0.0 {
191 -1.0e10
192 } else {
193 0.0
194 };
195 format!("{sentinel}.0")
196 }
197 };
198
199 let case_name = sanitize_quoted(&net.name, NAME_FORBIDDEN, ' ');
203 let _ = writeln!(
204 s,
205 "0, {}, {rev}, 0, {}, {} / powerio export: {}",
206 net.base_mva,
207 i32::from(modern),
208 num(net.base_frequency),
209 case_name
210 );
211 let _ = writeln!(s, "{case_name}");
212 let _ = writeln!(s);
213 if modern {
214 if let Some(sp) = &net.solver {
217 if let Some(t) = sp.zero_impedance_threshold {
218 let _ = writeln!(s, "GENERAL, THRSHZ={}", num(t));
219 }
220 let mut newton = Vec::new();
221 if let Some(t) = sp.newton_tolerance {
222 newton.push(format!("TOLN={}", num(t)));
223 }
224 if let Some(n) = sp.max_iterations {
225 newton.push(format!("ITMXN={n}"));
226 }
227 if !newton.is_empty() {
228 let _ = writeln!(s, "NEWTON, {}", newton.join(", "));
229 }
230 let flags: Vec<String> = [
231 ("ACTAPS", sp.adjust_taps),
232 ("AREAIN", sp.adjust_area_interchange),
233 ("PHSHFT", sp.adjust_phase_shift),
234 ("DCTAPS", sp.adjust_dc_taps),
235 ("SWSHNT", sp.adjust_switched_shunt),
236 ]
237 .into_iter()
238 .filter_map(|(name, v)| v.map(|b| format!("{name}={}", i32::from(b))))
239 .collect();
240 if !flags.is_empty() {
241 let _ = writeln!(s, "SOLVER, {}", flags.join(", "));
242 }
243 }
244 let _ = writeln!(s, "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA");
245 }
246
247 let mut bus_area: BTreeMap<BusId, (usize, usize)> = BTreeMap::new();
249 for b in &net.buses {
250 bus_area.insert(b.id, (b.area, b.zone));
251 let raw_name = b.name.as_deref().unwrap_or("");
252 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
253 if matches!(name, std::borrow::Cow::Owned(_)) {
254 sanitized_quoted += 1;
255 }
256 let _ = writeln!(
259 s,
260 "{}, '{:<12}', {}, {}, {}, {}, 1, {}, {}, {}, {}, {}, {}",
261 b.id,
262 name,
263 num(b.base_kv),
264 ide(b.kind),
265 b.area,
266 b.zone,
267 num(b.vm),
268 num(b.va),
269 num(b.vmax),
270 num(b.vmin),
271 num(b.evhi.unwrap_or(b.vmax)),
272 num(b.evlo.unwrap_or(b.vmin))
273 );
274 }
275 let _ = writeln!(s, "0 / END OF BUS DATA, BEGIN LOAD DATA");
276
277 let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
283 for l in &net.loads {
284 let (area, zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
285 let id = quoted_device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_quoted);
286 let (pl, ql, ip, iq, yp, yq) = load_components_for_write(l, &id, &mut warnings);
287 let owner = extra_i64(&l.extras, "psse_owner").unwrap_or(1);
288 let scal = typed_psse_scal(l, &id, &mut warnings)
289 .or_else(|| extra_i64(&l.extras, "psse_scal"))
290 .unwrap_or(1);
291 let intrpt = extra_i64(&l.extras, "psse_intrpt").unwrap_or(0);
292 let typed_load_type = l.voltage_model.as_ref().and_then(typed_psse_load_type);
293 if rev < 35 && typed_load_type.is_some() {
294 warnings.push(format!(
295 "PSS/E load at bus {} id {id:?}: load type requires revision 35; dropped",
296 l.bus
297 ));
298 }
299 let modern_tail = if rev >= 35 {
300 let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
301 let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
302 let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
303 let raw_loadtype = typed_load_type.or_else(|| {
304 l.extras
305 .get("psse_loadtype")
306 .and_then(Value::as_str)
307 .map(str::to_owned)
308 });
309 let loadtype =
310 sanitize_quoted(raw_loadtype.as_deref().unwrap_or(""), NAME_FORBIDDEN, ' ');
311 if matches!(loadtype, std::borrow::Cow::Owned(_)) {
312 sanitized_quoted += 1;
313 }
314 format!(
315 ", {}, {}, {flagstatus}, '{loadtype}'",
316 num(pdgen),
317 num(qdgen)
318 )
319 } else if modern {
320 let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
321 let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
322 let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
323 format!(", {}, {}, {flagstatus}", num(pdgen), num(qdgen))
324 } else {
325 String::new()
326 };
327 let _ = writeln!(
328 s,
329 "{}, '{id}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {owner}, {scal}, {intrpt}{modern_tail}",
330 l.bus,
331 i32::from(l.in_service),
332 area,
333 zone,
334 num(pl),
335 num(ql),
336 num(ip),
337 num(iq),
338 num(yp),
339 num(yq)
340 );
341 }
342 let _ = writeln!(s, "0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA");
343
344 let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
346 for sh in net.shunts.iter().filter(|s| s.control.is_none()) {
347 let id = quoted_device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_quoted);
348 let _ = writeln!(
349 s,
350 "{}, '{id}', {}, {}, {}",
351 sh.bus,
352 i32::from(sh.in_service),
353 num(sh.g),
354 num(sh.b)
355 );
356 }
357 let _ = writeln!(s, "0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA");
358
359 let mut gen_ids: BTreeMap<BusId, u32> = BTreeMap::new();
360 for g in &net.generators {
361 let id = positional_id(g.bus, &mut gen_ids);
362 let ireg = g.regulated_bus.map_or(0, |b| b.0);
364 let (nreg, baslod) = if rev >= 35 { (" 0,", " 0,") } else { ("", "") };
366 let _ = writeln!(
367 s,
368 "{}, '{id}', {}, {}, {}, {}, {}, {},{nreg} {}, 0, 1, 0, 0, 1, {}, 100, {}, {},{baslod} 1, 1",
369 g.bus,
370 num(g.pg),
371 num(g.qg),
372 num(g.qmax),
373 num(g.qmin),
374 num(g.vg),
375 ireg,
376 num(g.mbase),
377 i32::from(g.in_service),
378 num(g.pmax),
379 num(g.pmin)
380 );
381 }
382 let _ = writeln!(s, "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA");
383
384 let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
388 for (branch_index, br) in net
389 .branches
390 .iter()
391 .enumerate()
392 .filter(|(_, b)| !b.is_transformer())
393 {
394 let ckt = quoted_circuit_id(
395 br.extras.get("id").and_then(Value::as_str),
396 (br.from, br.to),
397 &mut branch_ids,
398 &mut sanitized_quoted,
399 );
400 let charging = br.terminal_charging();
401 let b_total = charging.total_b();
402 let b_mid = b_total / 2.0;
403 let bi = charging.b_fr - b_mid;
404 let bj = charging.b_to - b_mid;
405 if modern {
406 let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
410 let _ = writeln!(
411 s,
412 "{}, {}, '{ckt}', {}, {}, {}, ' ', {}, {}, {}, \
413 {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
414 br.from,
415 br.to,
416 num(br.r),
417 num(br.x),
418 num(b_total),
419 num(br.rate_a),
420 num(br.rate_b),
421 num(br.rate_c),
422 num(extra_ratings[0]),
423 num(extra_ratings[1]),
424 num(extra_ratings[2]),
425 num(extra_ratings[3]),
426 num(extra_ratings[4]),
427 num(extra_ratings[5]),
428 num(extra_ratings[6]),
429 num(extra_ratings[7]),
430 num(extra_ratings[8]),
431 num(charging.g_fr),
432 num(bi),
433 num(charging.g_to),
434 num(bj),
435 i32::from(br.in_service)
436 );
437 } else {
438 let _ = writeln!(
439 s,
440 "{}, {}, '{ckt}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
441 br.from,
442 br.to,
443 num(br.r),
444 num(br.x),
445 num(b_total),
446 num(br.rate_a),
447 num(br.rate_b),
448 num(br.rate_c),
449 num(charging.g_fr),
450 num(bi),
451 num(charging.g_to),
452 num(bj),
453 i32::from(br.in_service)
454 );
455 }
456 }
457 let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
458
459 for (branch_index, br) in net
460 .branches
461 .iter()
462 .enumerate()
463 .filter(|(_, b)| b.is_transformer())
464 {
465 let charging = br.terminal_charging();
473 let _ = writeln!(
474 s,
475 "{}, {}, 0, '1', 1, 1, 1, {}, {}, 2, ' ', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
476 br.from,
477 br.to,
478 num(charging.total_g()),
479 num(charging.total_b()),
480 i32::from(br.in_service)
481 );
482 let ctl = br.control.as_ref();
485 let sbase = ctl
486 .filter(|c| c.mva_base > 0.0)
487 .map_or(net.base_mva, |c| c.mva_base);
488 let cod = ctl.map_or(0, |c| mode_to_cod(c.mode));
489 let cont = ctl.and_then(|c| c.controlled_bus).map_or(0, |b| b.0);
490 let (rma, rmi, vma, vmi, ntp) = ctl.map_or((1.1, 0.9, 1.1, 0.9, 33), |c| {
491 (c.tap_max, c.tap_min, c.band_max, c.band_min, c.ntp)
492 });
493 let _ = writeln!(s, "{}, {}, {}", num(br.r), num(br.x), num(sbase));
494 if modern {
495 let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
499 let _ = writeln!(
500 s,
501 "{}, 0, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, \
502 {cod}, {cont}, 0, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
503 num(br.effective_tap()),
504 num(br.shift),
505 num(br.rate_a),
506 num(br.rate_b),
507 num(br.rate_c),
508 num(extra_ratings[0]),
509 num(extra_ratings[1]),
510 num(extra_ratings[2]),
511 num(extra_ratings[3]),
512 num(extra_ratings[4]),
513 num(extra_ratings[5]),
514 num(extra_ratings[6]),
515 num(extra_ratings[7]),
516 num(extra_ratings[8]),
517 num(rma),
518 num(rmi),
519 num(vma),
520 num(vmi)
521 );
522 } else {
523 let _ = writeln!(
524 s,
525 "{}, 0, {}, {}, {}, {}, {cod}, {cont}, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
526 num(br.effective_tap()),
527 num(br.shift),
528 num(br.rate_a),
529 num(br.rate_b),
530 num(br.rate_c),
531 num(rma),
532 num(rmi),
533 num(vma),
534 num(vmi)
535 );
536 }
537 let _ = writeln!(s, "1.0, 0");
538 }
539
540 for t in &net.transformers_3w {
544 let raw_name = t.name.as_deref().unwrap_or("");
545 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
546 if matches!(name, std::borrow::Cow::Owned(_)) {
547 sanitized_quoted += 1;
548 }
549 let _ = writeln!(
550 s,
551 "{}, {}, {}, '1', 1, 1, 1, {}, {}, 2, '{:<12}', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
552 t.windings[0].bus,
553 t.windings[1].bus,
554 t.windings[2].bus,
555 num(t.mag_g),
556 num(t.mag_b),
557 name,
558 i32::from(t.in_service)
559 );
560 let [z12, z23, z31] = t.z;
563 let _ = writeln!(
564 s,
565 "{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}",
566 num(z12.r),
567 num(z12.x),
568 num(z12.base_mva),
569 num(z23.r),
570 num(z23.x),
571 num(z23.base_mva),
572 num(z31.r),
573 num(z31.x),
574 num(z31.base_mva),
575 num(t.star_vm),
576 num(t.star_va)
577 );
578 for w in &t.windings {
579 if modern {
580 let _ = writeln!(
583 s,
584 "{}, {}, {}, {}, {}, {}, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, \
585 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
586 num(w.tap),
587 num(w.nominal_kv),
588 num(w.shift),
589 num(w.rate_a),
590 num(w.rate_b),
591 num(w.rate_c)
592 );
593 } else {
594 let _ = writeln!(
595 s,
596 "{}, {}, {}, {}, {}, {}, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
597 num(w.tap),
598 num(w.nominal_kv),
599 num(w.shift),
600 num(w.rate_a),
601 num(w.rate_b),
602 num(w.rate_c)
603 );
604 }
605 }
606 }
607 let _ = writeln!(s, "0 / END OF TRANSFORMER DATA, BEGIN AREA DATA");
608 for a in &net.areas {
609 let raw_name = a.name.as_deref().unwrap_or("");
610 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
611 if matches!(name, std::borrow::Cow::Owned(_)) {
612 sanitized_quoted += 1;
613 }
614 let _ = writeln!(
615 s,
616 "{}, {}, {}, {}, '{:<12}'",
617 a.number,
618 a.slack_bus.map_or(0, |b| b.0),
619 num(a.net_interchange),
620 num(a.tolerance),
621 name
622 );
623 }
624
625 let _ = writeln!(s, "{}", EMPTY_SECTIONS[0]);
629 for (i, dc) in net.hvdc.iter().enumerate() {
630 let raw_name = dc_str(&dc.extras, "psse_dc_name").unwrap_or_else(|| format!("DC{}", i + 1));
631 let name = sanitize_quoted(&raw_name, NAME_FORBIDDEN, ' ');
632 if matches!(name, std::borrow::Cow::Owned(_)) {
633 sanitized_quoted += 1;
634 }
635 let name = format!("'{name}'");
636 let mdc = if dc.in_service {
637 dc_int(&dc.extras, "psse_dc_mdc").unwrap_or(1)
638 } else {
639 0
640 };
641 let rdc = dc_f64(&dc.extras, "psse_dc_rdc").unwrap_or(0.0);
642 let vschd = dc_f64(&dc.extras, "psse_dc_vschd").unwrap_or(0.0);
643 let l1_tail = dc_tail(
644 &dc.extras,
645 "psse_dc_control_tail",
646 "0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0",
647 );
648 let rect_tail = dc_tail(&dc.extras, "psse_dc_rectifier_tail", DEFAULT_CONVERTER_TAIL);
649 let inv_tail = dc_tail(&dc.extras, "psse_dc_inverter_tail", DEFAULT_CONVERTER_TAIL);
650 let _ = writeln!(
651 s,
652 "{name}, {mdc}, {}, {}, {}, {l1_tail}",
653 num(rdc),
654 num(dc.pf),
655 num(vschd)
656 );
657 let _ = writeln!(s, "{}, {rect_tail}", dc.from);
658 let _ = writeln!(s, "{}, {inv_tail}", dc.to);
659 }
660 for line in &EMPTY_SECTIONS[1..=9] {
662 let _ = writeln!(s, "{line}");
663 }
664 let mut sw_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
670 for sh in net.shunts.iter().filter(|s| s.control.is_some()) {
671 let Some(c) = sh.control.as_ref() else {
672 continue;
673 };
674 let swrem = c.control_bus.map_or(0, |b| b.0);
675 let mut blocks = String::new();
676 for blk in &c.blocks {
677 if rev >= 35 {
678 let _ = write!(blocks, ", 1, {}, {}", blk.steps, num(blk.b));
681 } else {
682 let _ = write!(blocks, ", {}, {}", blk.steps, num(blk.b));
683 }
684 }
685 if rev >= 35 {
686 let id = quoted_device_id(&sh.extras, sh.bus, &mut sw_ids, &mut sanitized_quoted);
687 let _ = writeln!(
688 s,
689 "{}, '{id}', {}, 0, {}, {}, {}, {swrem}, 0, {}, '', {}{blocks}",
690 sh.bus,
691 mode_to_modsw(c.mode),
692 i32::from(sh.in_service),
693 num(c.vhigh),
694 num(c.vlow),
695 num(c.rmpct),
696 num(sh.b)
697 );
698 } else {
699 let _ = writeln!(
700 s,
701 "{}, {}, 0, {}, {}, {}, {swrem}, {}, '', {}{blocks}",
702 sh.bus,
703 mode_to_modsw(c.mode),
704 i32::from(sh.in_service),
705 num(c.vhigh),
706 num(c.vlow),
707 num(c.rmpct),
708 num(sh.b)
709 );
710 }
711 }
712 for line in &EMPTY_SECTIONS[10..] {
713 let _ = writeln!(s, "{line}");
714 }
715 let _ = writeln!(s, "Q");
716
717 if net
718 .hvdc
719 .iter()
720 .any(|d| !d.extras.contains_key("psse_dc_name"))
721 {
722 warnings.push(
723 "DC line converter detail (firing angles, converter transformer taps, reactive \
724 output) defaulted: PSS/E two-terminal DC is written from the power setpoint and \
725 line resistance only"
726 .into(),
727 );
728 }
729 if !net.storage.is_empty() {
730 warnings.push(format!(
731 "{} storage unit(s) dropped: PSS/E has no storage record",
732 net.storage.len()
733 ));
734 }
735 if net.generators.iter().any(|g| g.cost.is_some()) {
736 warnings.push("generator cost curves dropped: PSS/E .raw has no cost data".into());
737 }
738 if net.branches.iter().any(Branch::has_angle_limits) {
739 warnings.push(
740 "branch angle limits (angmin/angmax) dropped: PSS/E branch records carry none".into(),
741 );
742 }
743 let current_ratings = net
744 .branches
745 .iter()
746 .filter(|b| b.current_ratings.is_some())
747 .count();
748 if current_ratings > 0 {
749 warnings.push(format!(
750 "{current_ratings} branch current rating record(s) dropped: PSS/E branch ratings are MVA ratings"
751 ));
752 }
753 if !modern {
754 warn_psse_extra_branch_ratings_dropped(net, &mut warnings);
755 }
756 let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
757 if branch_solutions > 0 {
758 warnings.push(format!(
759 "{branch_solutions} branch solution value set(s) dropped: PSS/E RAW power flow result fields are not written"
760 ));
761 }
762 let transformer_terminal_shunts = net
763 .branches
764 .iter()
765 .filter(|b| {
766 b.is_transformer()
767 && b.charging
768 .is_some_and(|c| c.g_to.abs() > f64::EPSILON || c.b_to.abs() > f64::EPSILON)
769 })
770 .count();
771 if transformer_terminal_shunts > 0 {
772 warnings.push(format!(
773 "{transformer_terminal_shunts} transformer terminal admittance record(s) collapsed to magnetizing admittance: PSS/E transformer records cannot preserve terminal side assignment"
774 ));
775 }
776 if net.generators.iter().any(Generator::has_caps) {
777 warnings.push(
778 "generator ramp/capability columns dropped: PSS/E .raw has no equivalent fields".into(),
779 );
780 }
781 if nonfinite {
782 warnings.push("non-finite values written as ±1e10 sentinels (PSS/E has no Inf/NaN)".into());
783 }
784 if sanitized_quoted > 0 {
785 warnings.push(format!(
786 "{sanitized_quoted} quoted PSS/E field(s) contained a quote or '/' that would \
787 corrupt a record; replaced with spaces"
788 ));
789 }
790
791 Conversion { text: s, warnings }
792}
793
794fn ide(kind: BusType) -> u8 {
796 kind as u8 }
798
799fn quoted_device_id(
805 extras: &Extras,
806 bus: BusId,
807 used: &mut BTreeMap<BusId, BTreeSet<String>>,
808 sanitized_quoted: &mut usize,
809) -> String {
810 quoted_circuit_id(
811 extras.get("id").and_then(Value::as_str),
812 bus,
813 used,
814 sanitized_quoted,
815 )
816}
817
818fn quoted_circuit_id<K: Ord + Clone>(
819 preferred: Option<&str>,
820 key: K,
821 used: &mut BTreeMap<K, BTreeSet<String>>,
822 sanitized_quoted: &mut usize,
823) -> String {
824 let sanitized = preferred.map(|id| {
825 let cleaned = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
826 if matches!(cleaned, std::borrow::Cow::Owned(_)) {
827 *sanitized_quoted += 1;
828 }
829 cleaned.into_owned()
830 });
831 super::allocate_circuit_id(sanitized.as_deref(), key, used)
832}
833
834fn positional_id(bus: BusId, counters: &mut BTreeMap<BusId, u32>) -> String {
837 let n = counters.entry(bus).or_insert(0);
838 *n += 1;
839 n.to_string()
840}
841
842const DEFAULT_CONVERTER_TAIL: &str =
847 "1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0";
848
849const EMPTY_SECTIONS: [&str; 13] = [
850 "0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA",
851 "0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA",
852 "0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA",
853 "0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA",
854 "0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA",
855 "0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA",
856 "0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA",
857 "0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA",
858 "0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA",
859 "0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA",
860 "0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA",
861 "0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA",
862 "0 / END OF INDUCTION MACHINE DATA",
863];
864
865pub fn parse_psse(content: &str) -> Result<BalancedNetwork> {
871 let mut warnings = Vec::new();
872 parse_psse_source(Arc::new(content.to_owned()), None, &mut warnings)
873}
874
875pub(crate) fn header_rev(source: &str) -> u32 {
880 let Some(header) = source
881 .lines()
882 .map(str::trim)
883 .find(|line| !line.is_empty() && !is_comment(line))
884 else {
885 return 33;
886 };
887 fields(header)
888 .get(2)
889 .and_then(|f| f.parse::<f64>().ok())
890 .filter(|v| v.is_finite() && *v >= 0.0)
891 .map_or(33, |v| v as u32)
892}
893
894#[expect(clippy::too_many_lines)]
900pub(crate) fn parse_psse_source(
901 source: Arc<String>,
902 name_hint: Option<&str>,
903 warnings: &mut Vec<String>,
904) -> Result<BalancedNetwork> {
905 let content: &str = &source;
906 let mut lines = content.lines();
907
908 let header = lines
910 .by_ref()
911 .find(|line| {
912 let line = line.trim();
913 !line.is_empty() && !is_comment(line)
914 })
915 .ok_or_else(|| Error::FormatRead {
916 format: FMT,
917 message: "empty file".into(),
918 })?;
919 let header_fields = fields(header);
920 let base_mva = header_fields
921 .get(1)
922 .and_then(|f| f.parse::<f64>().ok())
923 .ok_or_else(|| Error::FormatRead {
924 format: FMT,
925 message: "missing SBASE in header".into(),
926 })?;
927 let raw_rev = header_fields
928 .get(2)
929 .and_then(|f| f.parse::<f64>().ok())
930 .filter(|v| v.is_finite() && *v >= 0.0)
931 .map_or(33, |v| v as u32);
932 let base_frequency = header_fields
935 .get(5)
936 .and_then(|f| f.parse::<f64>().ok())
937 .filter(|v| v.is_finite() && *v > 0.0)
938 .unwrap_or(crate::network::DEFAULT_BASE_FREQUENCY);
939 let title = lines.next().unwrap_or("").trim();
941 let name = if title.is_empty() {
942 name_hint.unwrap_or("case").to_string()
943 } else {
944 title.to_string()
945 };
946 lines.next(); let mut buses = Vec::new();
949 let mut loads = Vec::new();
950 let mut shunts = Vec::new();
951 let mut generators = Vec::new();
952 let mut branches = Vec::new();
953 let mut transformers_3w = Vec::new();
954 let mut hvdc = Vec::new();
955 let mut areas = Vec::new();
956 let mut solver = SolverParams::default();
957 let mut bus_base_kv: BTreeMap<BusId, f64> = BTreeMap::new();
958 let mut unmodeled_sections: BTreeMap<String, usize> = BTreeMap::new();
959
960 let mut section = Section::Bus;
963 let mut saw_bus_marker = false;
964 let mut skipped_section_name: Option<String> = None;
965 let mut lines = lines.peekable();
966 while let Some(raw) = lines.next() {
967 let line = raw.trim();
968 if line.is_empty() {
969 continue;
970 }
971 if is_comment(line) {
972 continue;
973 }
974 if line == "Q" {
975 break;
976 }
977 if is_terminator(line) {
978 let next_section = section_after_marker(line);
983 skipped_section_name =
984 introduced_section_name(line).filter(|_| matches!(next_section, Section::Skip));
985 section = next_section;
986 saw_bus_marker |= matches!(section, Section::Bus);
987 continue;
988 }
989 let f = fields(line);
990 match section {
991 Section::Bus if !saw_bus_marker && buses.is_empty() && is_system_wide_record(&f) => {
992 section = Section::SystemWide;
995 parse_solver_line(&f, &mut solver);
996 }
997 Section::Bus => {
998 let bus = read_bus(&f)?;
999 bus_base_kv.insert(bus.id, bus.base_kv);
1000 buses.push(bus);
1001 }
1002 Section::Load => loads.push(read_load(&f, raw_rev, warnings)?),
1003 Section::FixedShunt => shunts.push(read_shunt(&f)?),
1004 Section::SwitchedShunt => shunts.push(read_switched_shunt(&f, raw_rev)?),
1005 Section::Generator => generators.push(read_gen(&f, raw_rev)?),
1006 Section::Branch => branches.push(read_branch(&f, raw_rev)?),
1007 Section::Transformer => {
1008 let two_winding = int_at(&f, 2, 0)? == 0;
1013 let l2 = next_continuation_line(
1014 &mut lines,
1015 "transformer",
1016 "transformer impedance line",
1017 )?;
1018 let l3 = next_continuation_line(&mut lines, "transformer", "winding data line 1")?;
1019 let l4 = next_continuation_line(&mut lines, "transformer", "winding data line 2")?;
1020 if two_winding {
1021 if int_at(&f, 6, 1)? != 1 && num_at(&f, 8, 0.0)? != 0.0 {
1025 warnings.push(format!(
1026 "transformer {}-{}: magnetizing data with CM != 1 dropped \
1027 (only CM = 1 p.u. susceptance is read as branch charging)",
1028 f.first().map_or("?", String::as_str),
1029 f.get(1).map_or("?", String::as_str),
1030 ));
1031 }
1032 branches.push(read_transformer(
1033 &f,
1034 &fields(l2),
1035 &fields(l3),
1036 &fields(l4),
1037 raw_rev,
1038 base_mva,
1039 &bus_base_kv,
1040 warnings,
1041 )?);
1042 } else {
1043 let l5 =
1044 next_continuation_line(&mut lines, "transformer", "winding data line 3")?;
1045 transformers_3w.push(read_transformer_3w(
1046 &f,
1047 &fields(l2),
1048 &fields(l3),
1049 &fields(l4),
1050 &fields(l5),
1051 base_mva,
1052 &bus_base_kv,
1053 warnings,
1054 )?);
1055 }
1056 }
1057 Section::TwoTerminalDc => {
1058 let rectifier =
1061 next_continuation_line(&mut lines, "two-terminal DC", "rectifier line")?;
1062 let inverter =
1063 next_continuation_line(&mut lines, "two-terminal DC", "inverter line")?;
1064 hvdc.push(read_dc_line(&f, &fields(rectifier), &fields(inverter))?);
1065 }
1066 Section::Area => areas.push(read_area(&f)?),
1067 Section::SystemWide => parse_solver_line(&f, &mut solver),
1068 Section::Skip => {
1069 if let Some(name) = skipped_section_name.as_ref() {
1070 *unmodeled_sections.entry(name.clone()).or_default() += 1;
1071 }
1072 }
1073 }
1074 }
1075
1076 warn_unmodeled_sections(unmodeled_sections, warnings);
1077
1078 let mut net = BalancedNetwork {
1079 name,
1080 base_mva,
1081 base_frequency,
1082 geo: None,
1083 buses,
1084 loads,
1085 shunts,
1086 branches,
1087 switches: Vec::new(),
1088 generators,
1089 storage: Vec::new(),
1090 hvdc,
1091 transformers_3w,
1092 areas,
1093 solver: (!solver.is_empty()).then_some(solver),
1094 source_format: SourceFormat::Psse,
1095 source: Some(source),
1096 };
1097 drop_stale_control_pointers(&mut net, warnings);
1098 net.check_references(FMT)?;
1099 Ok(net)
1100}
1101
1102#[derive(Clone, Copy)]
1103enum Section {
1104 Bus,
1105 Load,
1106 FixedShunt,
1107 SwitchedShunt,
1108 Generator,
1109 Branch,
1110 Transformer,
1111 TwoTerminalDc,
1112 Area,
1113 SystemWide,
1114 Skip,
1115}
1116
1117fn section_after_marker(line: &str) -> Section {
1121 match introduced_section_name(line).as_deref() {
1122 Some("BUS") => Section::Bus,
1123 Some("LOAD") => Section::Load,
1124 Some("FIXED SHUNT") => Section::FixedShunt,
1125 Some("SWITCHED SHUNT") => Section::SwitchedShunt,
1126 Some("GENERATOR" | "GEN") => Section::Generator,
1127 Some("BRANCH") => Section::Branch,
1128 Some("TRANSFORMER") => Section::Transformer,
1129 Some("TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC") => {
1130 Section::TwoTerminalDc
1131 }
1132 Some("AREA" | "AREA INTERCHANGE") => Section::Area,
1133 _ => Section::Skip,
1134 }
1135}
1136
1137fn is_terminator(line: &str) -> bool {
1139 fields(line).first().map(String::as_str) == Some("0")
1140}
1141
1142fn next_continuation_line<'a>(
1143 lines: &mut std::iter::Peekable<std::str::Lines<'a>>,
1144 record: &str,
1145 expected: &str,
1146) -> Result<&'a str> {
1147 for line in lines.by_ref().map(str::trim) {
1148 if line.is_empty() || is_comment(line) {
1149 continue;
1150 }
1151 if line.eq_ignore_ascii_case("q") || is_section_marker(line) || is_bare_terminator(line) {
1152 return Err(Error::FormatRead {
1153 format: FMT,
1154 message: format!(
1155 "PSS/E {record} record ended before {expected}: found section terminator `{line}`"
1156 ),
1157 });
1158 }
1159 return Ok(line);
1160 }
1161 Err(Error::FormatRead {
1162 format: FMT,
1163 message: format!("PSS/E {record} record ended before {expected}"),
1164 })
1165}
1166
1167fn is_bare_terminator(line: &str) -> bool {
1168 let f = fields(line);
1169 f.len() == 1 && f.first().map(String::as_str) == Some("0")
1170}
1171
1172fn transformer_basis_codes(f: &[String]) -> Result<(i64, i64)> {
1173 let cw = num_at(f, 4, 1.0)?;
1174 if cw.fract() != 0.0 {
1175 return Err(bad_field(4, f.get(4).map_or("", String::as_str)));
1176 }
1177 let cz = num_at(f, 5, 1.0)?;
1178 if cz.fract() != 0.0 {
1179 return Err(bad_field(5, f.get(5).map_or("", String::as_str)));
1180 }
1181 #[allow(clippy::cast_possible_truncation)]
1182 Ok((cw as i64, cz as i64))
1183}
1184
1185fn transformer_label(f: &[String]) -> String {
1186 let i = f.first().map_or("?", String::as_str);
1187 let j = f.get(1).map_or("?", String::as_str);
1188 let k = f.get(2).map_or("?", String::as_str);
1189 let id = f.get(3).map_or("", String::as_str);
1190 format!("{i}-{j}-{k} id {id:?}")
1191}
1192
1193#[expect(clippy::too_many_arguments)]
1194fn convert_transformer_impedance(
1195 r: f64,
1196 x: f64,
1197 sbase: f64,
1198 system_base: f64,
1199 cz: i64,
1200 label: &str,
1201 pair: &str,
1202 warnings: &mut Vec<String>,
1203) -> (f64, f64) {
1204 let base_ok = sbase.is_finite() && sbase > 0.0;
1205 match cz {
1206 1 => (r, x),
1207 2 => {
1208 if base_ok {
1209 let scale = system_base / sbase;
1210 (r * scale, x * scale)
1211 } else {
1212 warnings.push(format!(
1213 "PSS/E transformer {label} pair {pair}: CZ=2 impedance has invalid SBASE {sbase}; read as system-base p.u."
1214 ));
1215 (r, x)
1216 }
1217 }
1218 3 => {
1219 if !base_ok {
1220 warnings.push(format!(
1221 "PSS/E transformer {label} pair {pair}: CZ=3 impedance has invalid SBASE {sbase}; read as system-base p.u."
1222 ));
1223 return (r, x);
1224 }
1225 let r_pair = (r / 1_000_000.0) / sbase;
1226 let z_pair = x.abs();
1227 let x_pair = (z_pair.mul_add(z_pair, -(r_pair * r_pair)))
1228 .max(0.0)
1229 .sqrt()
1230 .copysign(x);
1231 let scale = system_base / sbase;
1232 (r_pair * scale, x_pair * scale)
1233 }
1234 other => {
1235 warnings.push(format!(
1236 "PSS/E transformer {label} pair {pair}: unsupported CZ={other}; read impedance as system-base p.u."
1237 ));
1238 (r, x)
1239 }
1240 }
1241}
1242
1243fn default_windv(cw: i64, bus: BusId, bus_base_kv: &BTreeMap<BusId, f64>) -> f64 {
1244 if cw == 2 {
1245 bus_base_kv
1246 .get(&bus)
1247 .copied()
1248 .filter(|v| *v > 0.0)
1249 .unwrap_or(1.0)
1250 } else {
1251 1.0
1252 }
1253}
1254
1255fn winding_ratio(
1256 w: &[String],
1257 bus: BusId,
1258 cw: i64,
1259 bus_base_kv: &BTreeMap<BusId, f64>,
1260 label: &str,
1261 winding: &str,
1262 warnings: &mut Vec<String>,
1263) -> Result<f64> {
1264 let windv = num_at(w, 0, default_windv(cw, bus, bus_base_kv))?;
1265 let nomv = num_at(w, 1, 0.0)?;
1266 let base_kv = bus_base_kv.get(&bus).copied().unwrap_or(0.0);
1267 let needs_base = matches!(cw, 2 | 3);
1268 if needs_base && !(base_kv.is_finite() && base_kv > 0.0) {
1269 warnings.push(format!(
1270 "PSS/E transformer {label} {winding}: CW={cw} needs a positive bus base kV for bus {bus}; read WINDV as a p.u. tap ratio"
1271 ));
1272 return Ok(windv);
1273 }
1274 match cw {
1275 1 => Ok(windv),
1276 2 => Ok(windv / base_kv),
1277 3 => {
1278 let nominal = if nomv.is_finite() && nomv > 0.0 {
1279 nomv
1280 } else {
1281 base_kv
1282 };
1283 Ok(windv * nominal / base_kv)
1284 }
1285 other => {
1286 warnings.push(format!(
1287 "PSS/E transformer {label} {winding}: unsupported CW={other}; read WINDV as a p.u. tap ratio"
1288 ));
1289 Ok(windv)
1290 }
1291 }
1292}
1293
1294#[expect(clippy::too_many_arguments)]
1295fn two_winding_tap(
1296 l1: &[String],
1297 l3: &[String],
1298 l4: &[String],
1299 from: BusId,
1300 to: BusId,
1301 cw: i64,
1302 bus_base_kv: &BTreeMap<BusId, f64>,
1303 warnings: &mut Vec<String>,
1304) -> Result<f64> {
1305 let label = transformer_label(l1);
1306 let ratio1 = winding_ratio(l3, from, cw, bus_base_kv, &label, "winding 1", warnings)?;
1307 let ratio2 = winding_ratio(l4, to, cw, bus_base_kv, &label, "winding 2", warnings)?;
1308 if ratio2.abs() <= f64::EPSILON {
1309 warnings.push(format!(
1310 "PSS/E transformer {label}: winding 2 ratio is zero; used winding 1 ratio as the branch tap"
1311 ));
1312 Ok(ratio1)
1313 } else {
1314 Ok(ratio1 / ratio2)
1315 }
1316}
1317
1318fn is_section_marker(line: &str) -> bool {
1321 if !is_terminator(line) {
1322 return false;
1323 }
1324 let u = line.to_ascii_uppercase();
1325 u.contains("END OF") || u.contains("BEGIN ") || u.contains("START OF ")
1326}
1327
1328fn introduced_section_name(line: &str) -> Option<String> {
1331 let u = line.to_ascii_uppercase();
1332 let (start, prefix_len) = u
1333 .find("BEGIN ")
1334 .map(|idx| (idx, "BEGIN ".len()))
1335 .or_else(|| u.find("START OF ").map(|idx| (idx, "START OF ".len())))?;
1336 let start = start + prefix_len;
1337 let rest = &u[start..];
1338 let end = rest.find(" DATA")?;
1339 Some(rest[..end].trim().to_string())
1340}
1341
1342fn warn_unmodeled_sections(totals: BTreeMap<String, usize>, warnings: &mut Vec<String>) {
1348 for (name, rows) in totals {
1349 warnings.push(format!(
1350 "PSS/E {name} section ({rows} record line(s)) is not modeled: preserved only in a \
1351 same-format .raw echo, dropped on any other write"
1352 ));
1353 }
1354}
1355
1356fn drop_stale_control_pointers(net: &mut BalancedNetwork, warnings: &mut Vec<String>) {
1357 let bus_ids: BTreeSet<BusId> = net.buses.iter().map(|b| b.id).collect();
1358 let missing = |bus: BusId| !bus_ids.contains(&bus);
1359
1360 for (idx, g) in net.generators.iter_mut().enumerate() {
1361 let Some(bus) = g.regulated_bus.filter(|b| missing(*b)) else {
1362 continue;
1363 };
1364 warnings.push(format!(
1365 "PSS/E GENERATOR DATA record {} at bus {}: IREG references missing bus id {}; dropped remote voltage control",
1366 idx + 1,
1367 g.bus,
1368 bus
1369 ));
1370 g.regulated_bus = None;
1371 }
1372
1373 for (idx, br) in net.branches.iter_mut().enumerate() {
1374 let Some(control) = br.control.as_mut() else {
1375 continue;
1376 };
1377 let Some(bus) = control.controlled_bus.filter(|b| missing(*b)) else {
1378 continue;
1379 };
1380 warnings.push(format!(
1381 "PSS/E TRANSFORMER DATA record {} ({}-{}): CONT references missing bus id {}; dropped transformer control pointer",
1382 idx + 1,
1383 br.from,
1384 br.to,
1385 bus
1386 ));
1387 control.controlled_bus = None;
1388 }
1389
1390 for (idx, shunt) in net.shunts.iter_mut().enumerate() {
1391 let Some(control) = shunt.control.as_mut() else {
1392 continue;
1393 };
1394 let Some(bus) = control.control_bus.filter(|b| missing(*b)) else {
1395 continue;
1396 };
1397 warnings.push(format!(
1398 "PSS/E SWITCHED SHUNT DATA record {} at bus {}: SWREM references missing bus id {}; dropped switched shunt control pointer",
1399 idx + 1,
1400 shunt.bus,
1401 bus
1402 ));
1403 control.control_bus = None;
1404 }
1405
1406 for (idx, area) in net.areas.iter_mut().enumerate() {
1407 let Some(bus) = area.slack_bus.filter(|b| missing(*b)) else {
1408 continue;
1409 };
1410 warnings.push(format!(
1411 "PSS/E AREA DATA record {} area {}: ISW references missing bus id {}; dropped area swing pointer",
1412 idx + 1,
1413 area.number,
1414 bus
1415 ));
1416 area.slack_bus = None;
1417 }
1418}
1419
1420fn is_comment(line: &str) -> bool {
1421 line.starts_with("@!") || line.starts_with('@')
1422}
1423
1424fn is_system_wide_record(f: &[String]) -> bool {
1425 matches!(
1426 f.first().map(|s| s.to_ascii_uppercase()),
1427 Some(first) if matches!(first.as_str(), "GENERAL" | "RATING" | "NEWTON" | "SOLVER")
1428 )
1429}
1430
1431fn parse_solver_line(f: &[String], solver: &mut SolverParams) {
1435 let Some(keyword) = f.first().map(|s| s.to_ascii_uppercase()) else {
1436 return;
1437 };
1438 for tok in &f[1..] {
1439 let Some((key, val)) = tok.split_once('=') else {
1440 continue;
1441 };
1442 let (key, val) = (key.trim().to_ascii_uppercase(), val.trim());
1443 match (keyword.as_str(), key.as_str()) {
1444 ("GENERAL", "THRSHZ") => solver.zero_impedance_threshold = val.parse().ok(),
1445 ("NEWTON", "TOLN") => solver.newton_tolerance = val.parse().ok(),
1446 ("NEWTON", "ITMXN") => solver.max_iterations = val.parse().ok(),
1447 ("SOLVER", "ACTAPS") => solver.adjust_taps = Some(parse_enable(val)),
1448 ("SOLVER", "AREAIN") => solver.adjust_area_interchange = Some(parse_enable(val)),
1449 ("SOLVER", "PHSHFT") => solver.adjust_phase_shift = Some(parse_enable(val)),
1450 ("SOLVER", "DCTAPS") => solver.adjust_dc_taps = Some(parse_enable(val)),
1451 ("SOLVER", "SWSHNT") => solver.adjust_switched_shunt = Some(parse_enable(val)),
1452 _ => {}
1453 }
1454 }
1455}
1456
1457fn parse_enable(val: &str) -> bool {
1460 val.parse::<f64>().map_or_else(
1461 |_| !matches!(val.to_ascii_uppercase().as_str(), "DISABLED" | "OFF" | "NO"),
1462 |n| n != 0.0,
1463 )
1464}
1465
1466fn strip_inline_comment(line: &str) -> &str {
1469 let mut quoted = false;
1470 for (i, c) in line.char_indices() {
1471 match c {
1472 '\'' => quoted = !quoted,
1473 '/' if !quoted => return &line[..i],
1474 _ => {}
1475 }
1476 }
1477 line
1478}
1479
1480fn fields(line: &str) -> Vec<String> {
1485 let code = strip_inline_comment(line);
1486 let comma_delimited = code.contains(',');
1487 let mut out = Vec::with_capacity(if comma_delimited {
1493 code.bytes().filter(|b| *b == b',').count() + 1
1494 } else {
1495 8
1496 });
1497 let mut cur = String::with_capacity(32);
1498 let mut quoted = false;
1499 for c in code.chars() {
1500 match c {
1501 '\'' => quoted = !quoted,
1502 ',' if !quoted && comma_delimited => {
1503 out.push(cur.trim().to_owned());
1504 cur.clear();
1505 }
1506 c if c.is_whitespace() && !quoted && !comma_delimited => {
1507 if !cur.is_empty() {
1508 out.push(cur.clone());
1509 cur.clear();
1510 }
1511 }
1512 c => cur.push(c),
1513 }
1514 }
1515 let last = cur.trim().to_owned();
1516 if comma_delimited || !last.is_empty() {
1517 out.push(last);
1518 }
1519 out
1520}
1521
1522fn bad_field(i: usize, tok: &str) -> Error {
1523 Error::FormatRead {
1524 format: FMT,
1525 message: format!("field {i} {tok:?} is not a number"),
1526 }
1527}
1528
1529fn num_at(f: &[String], i: usize, default: f64) -> Result<f64> {
1534 match f.get(i).map(String::as_str) {
1535 None | Some("") => Ok(default),
1536 Some(s) => s.parse().map_err(|_| bad_field(i, s)),
1537 }
1538}
1539fn id_at(f: &[String], i: usize, default: usize) -> Result<usize> {
1541 match f.get(i).map(String::as_str) {
1542 None | Some("") => Ok(default),
1543 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1544 Some(s) => s
1545 .parse::<f64>()
1546 .map(|v| v as usize)
1547 .map_err(|_| bad_field(i, s)),
1548 }
1549}
1550fn on_at(f: &[String], i: usize, default: bool) -> Result<bool> {
1552 match f.get(i).map(String::as_str) {
1553 None | Some("") => Ok(default),
1554 Some(s) => s
1555 .parse::<f64>()
1556 .map(|v| v != 0.0)
1557 .map_err(|_| bad_field(i, s)),
1558 }
1559}
1560fn int_at(f: &[String], i: usize, default: i64) -> Result<i64> {
1562 match f.get(i).map(String::as_str) {
1563 None | Some("") => Ok(default),
1564 #[allow(clippy::cast_possible_truncation)]
1567 Some(s) => s
1568 .parse::<f64>()
1569 .map(|v| v as i64)
1570 .map_err(|_| bad_field(i, s)),
1571 }
1572}
1573
1574fn bustype(code: i64) -> BusType {
1575 match code {
1576 2 => BusType::Pv,
1577 3 => BusType::Ref,
1578 4 => BusType::Isolated,
1579 _ => BusType::Pq,
1580 }
1581}
1582
1583#[allow(clippy::float_cmp)]
1586fn read_bus(f: &[String]) -> Result<Bus> {
1587 let id = f
1589 .first()
1590 .and_then(|x| x.parse::<f64>().ok())
1591 .ok_or_else(|| Error::FormatRead {
1592 format: FMT,
1593 message: "bus record missing numeric id (field I)".into(),
1594 })? as usize;
1595 let name = f
1596 .get(1)
1597 .filter(|n| !n.is_empty())
1598 .map(|n| n.trim().to_string());
1599 let vmax = num_at(f, 9, 1.1)?;
1600 let vmin = num_at(f, 10, 0.9)?;
1601 let evhi = num_at(f, 11, vmax)?;
1604 let evlo = num_at(f, 12, vmin)?;
1605 Ok(Bus {
1606 id: BusId(id),
1607 kind: bustype(int_at(f, 3, 1)?),
1608 vm: num_at(f, 7, 1.0)?,
1609 va: num_at(f, 8, 0.0)?,
1610 base_kv: num_at(f, 2, 0.0)?,
1611 vmax,
1612 vmin,
1613 evhi: (evhi != vmax).then_some(evhi),
1614 evlo: (evlo != vmin).then_some(evlo),
1615 area: id_at(f, 4, 0)?,
1616 zone: id_at(f, 5, 0)?,
1617 name,
1618 uid: None,
1619 location: None,
1620 extras: Extras::new(),
1621 })
1622}
1623
1624fn device_extras(f: &[String], i: usize) -> Extras {
1628 let mut extras = Extras::new();
1629 if let Some(id) = f.get(i).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1630 extras.insert("id".into(), Value::String(id.to_string()));
1631 }
1632 extras
1633}
1634
1635fn read_load(f: &[String], raw_rev: u32, warnings: &mut Vec<String>) -> Result<Load> {
1636 let bus = id_at(f, 0, 0)?;
1638 let id = f.get(1).map_or("", |s| s.trim());
1639 let pl = num_at(f, 5, 0.0)?;
1640 let ql = num_at(f, 6, 0.0)?;
1641 let ip = num_at(f, 7, 0.0)?;
1642 let iq = num_at(f, 8, 0.0)?;
1643 let yp = num_at(f, 9, 0.0)?;
1644 let yq = num_at(f, 10, 0.0)?;
1645 let mut extras = device_extras(f, 1);
1646 for (key, value) in [
1647 ("psse_pl", pl),
1648 ("psse_ql", ql),
1649 ("psse_ip", ip),
1650 ("psse_iq", iq),
1651 ("psse_yp", yp),
1652 ("psse_yq", yq),
1653 ] {
1654 extras.insert(key.into(), jnum(value));
1655 }
1656 for (field, key, default) in [
1657 (11, "psse_owner", 1_i64),
1658 (12, "psse_scal", 1_i64),
1659 (13, "psse_intrpt", 0_i64),
1660 ] {
1661 let value = int_at(f, field, default)?;
1662 if value != default {
1663 extras.insert(key.into(), Value::from(value));
1664 }
1665 }
1666 if raw_rev >= 34 {
1667 for (field, key) in [(14, "psse_pdgen"), (15, "psse_qdgen")] {
1668 let value = num_at(f, field, 0.0)?;
1669 if value != 0.0 {
1670 extras.insert(key.into(), jnum(value));
1671 }
1672 }
1673 let flag = int_at(f, 16, 0)?;
1674 if flag != 0 {
1675 extras.insert("psse_flagstatus".into(), Value::from(flag));
1676 }
1677 }
1678 if raw_rev >= 35 {
1679 if let Some(loadtype) = f.get(17).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1680 extras.insert("psse_loadtype".into(), Value::String(loadtype.to_string()));
1681 }
1682 }
1683 let scal = int_at(f, 12, 1)?;
1684 let load_type = f.get(17).and_then(|s| s.trim().parse::<i32>().ok());
1685 let has_zip_components = [ip, iq, yp, yq].iter().any(|v| *v != 0.0);
1686 let voltage_model =
1687 (has_zip_components || scal != 1 || load_type.is_some()).then_some(LoadVoltageModel::Zip {
1688 p_constant_power: pl,
1689 q_constant_power: ql,
1690 p_constant_current: ip,
1691 q_constant_current: iq,
1692 p_constant_impedance: yp,
1693 q_constant_impedance: yq,
1694 v_nom: None,
1695 load_type,
1696 scaling: (scal != 1).then_some(scal as f64),
1697 });
1698 let has_load_options = extras.contains_key("psse_intrpt")
1699 || extras.contains_key("psse_pdgen")
1700 || extras.contains_key("psse_qdgen")
1701 || extras.contains_key("psse_flagstatus");
1702 if has_load_options {
1703 warnings.push(format!(
1704 "PSS/E load at bus {bus} id {id:?}: interruptible/DG/flag fields are retained in extras"
1705 ));
1706 }
1707 Ok(Load {
1708 bus: BusId(bus),
1709 p: pl + ip + yp,
1710 q: ql + iq + yq,
1711 voltage_model,
1712 in_service: on_at(f, 2, true)?,
1713 uid: None,
1714 extras,
1715 })
1716}
1717
1718fn read_shunt(f: &[String]) -> Result<Shunt> {
1719 Ok(Shunt {
1721 bus: BusId(id_at(f, 0, 0)?),
1722 g: num_at(f, 3, 0.0)?,
1723 b: num_at(f, 4, 0.0)?,
1724 in_service: on_at(f, 2, true)?,
1725 control: None,
1726 uid: None,
1727 extras: device_extras(f, 1),
1728 })
1729}
1730
1731fn read_switched_shunt(f: &[String], rev: u32) -> Result<Shunt> {
1732 let o = usize::from(rev >= 35);
1739 let o2 = 2 * o;
1740 let bus = id_at(f, 0, 0)?;
1741 let swrem = id_at(f, 6 + o, 0)?;
1742 let mut blocks = Vec::new();
1746 let mut i = 10 + o2;
1747 let stride = 2 + o;
1748 while i + stride <= f.len() {
1749 let steps = int_at(f, i + o, 0)?;
1750 let b = num_at(f, i + o + 1, 0.0)?;
1751 if steps == 0 && b == 0.0 {
1752 break;
1753 }
1754 blocks.push(ShuntBlock {
1755 steps: steps.clamp(0, i64::from(u32::MAX)) as u32,
1756 b,
1757 });
1758 i += stride;
1759 }
1760 let control = SwitchedShuntControl {
1761 mode: modsw_to_mode(int_at(f, 1 + o, 1)?),
1762 vhigh: num_at(f, 4 + o, 0.0)?,
1763 vlow: num_at(f, 5 + o, 0.0)?,
1764 control_bus: (swrem != 0 && swrem != bus).then_some(BusId(swrem)),
1765 rmpct: num_at(f, 7 + o2, 100.0)?,
1766 blocks,
1767 };
1768 Ok(Shunt {
1769 bus: BusId(bus),
1770 g: 0.0,
1771 b: num_at(f, 9 + o2, 0.0)?,
1772 in_service: on_at(f, 3 + o, true)?,
1773 control: Some(control),
1774 uid: None,
1775 extras: if rev >= 35 {
1777 device_extras(f, 1)
1778 } else {
1779 Extras::new()
1780 },
1781 })
1782}
1783
1784fn modsw_to_mode(modsw: i64) -> SwitchedShuntMode {
1786 match modsw {
1787 0 => SwitchedShuntMode::Locked,
1788 1 => SwitchedShuntMode::Continuous,
1789 _ => SwitchedShuntMode::Discrete,
1790 }
1791}
1792
1793fn mode_to_modsw(mode: SwitchedShuntMode) -> i64 {
1796 match mode {
1797 SwitchedShuntMode::Locked => 0,
1798 SwitchedShuntMode::Continuous => 1,
1799 SwitchedShuntMode::Discrete => 2,
1800 }
1801}
1802
1803fn read_area(f: &[String]) -> Result<Area> {
1804 let isw = id_at(f, 1, 0)?;
1806 Ok(Area {
1807 number: id_at(f, 0, 0)?,
1808 slack_bus: (isw != 0).then_some(BusId(isw)),
1809 net_interchange: num_at(f, 2, 0.0)?,
1810 tolerance: num_at(f, 3, 0.0)?,
1811 name: f
1812 .get(4)
1813 .filter(|n| !n.trim().is_empty())
1814 .map(|n| n.trim().to_string()),
1815 })
1816}
1817
1818fn read_gen(f: &[String], raw_rev: u32) -> Result<Generator> {
1819 let o = usize::from(raw_rev >= 35);
1823 let bus = id_at(f, 0, 0)?;
1824 let ireg = id_at(f, 7, 0)?;
1827 Ok(Generator {
1828 bus: BusId(bus),
1829 pg: num_at(f, 2, 0.0)?,
1830 qg: num_at(f, 3, 0.0)?,
1831 qmax: num_at(f, 4, 0.0)?,
1832 qmin: num_at(f, 5, 0.0)?,
1833 vg: num_at(f, 6, 1.0)?,
1834 mbase: num_at(f, 8 + o, 100.0)?,
1835 in_service: on_at(f, 14 + o, true)?,
1836 pmax: num_at(f, 16 + o, 0.0)?,
1837 pmin: num_at(f, 17 + o, 0.0)?,
1838 cost: None,
1839 caps: Default::default(),
1840 regulated_bus: (ireg != 0 && ireg != bus).then_some(BusId(ireg)),
1841 uid: None,
1842 })
1843}
1844
1845fn read_branch(f: &[String], raw_rev: u32) -> Result<Branch> {
1846 let named_record = raw_rev >= 34 && f.len() >= 24;
1851 let rating = if named_record { 7 } else { 6 };
1852 let status = if named_record { 23 } else { 13 };
1853 let shunt = if named_record { 19 } else { 9 };
1854 let br_b = num_at(f, 5, 0.0)?;
1855 let g_fr = num_at(f, shunt, 0.0)?;
1856 let b_fr_extra = num_at(f, shunt + 1, 0.0)?;
1857 let g_to = num_at(f, shunt + 2, 0.0)?;
1858 let b_to_extra = num_at(f, shunt + 3, 0.0)?;
1859 let b_fr = br_b / 2.0 + b_fr_extra;
1860 let b_to = br_b / 2.0 + b_to_extra;
1861 Ok(Branch {
1862 from: BusId(id_at(f, 0, 0)?),
1863 to: BusId(id_at(f, 1, 0)?),
1864 r: num_at(f, 3, 0.0)?,
1865 x: num_at(f, 4, 0.0)?,
1866 b: b_fr + b_to,
1867 charging: Some(BranchCharging {
1868 g_fr,
1869 b_fr,
1870 g_to,
1871 b_to,
1872 }),
1873 rate_a: num_at(f, rating, 0.0)?,
1874 rate_b: num_at(f, rating + 1, 0.0)?,
1875 rate_c: num_at(f, rating + 2, 0.0)?,
1876 rating_sets: read_extra_branch_ratings(f, rating, named_record)?,
1877 current_ratings: None,
1878 tap: 0.0,
1879 shift: 0.0,
1880 in_service: on_at(f, status, true)?,
1881 angmin: -360.0,
1882 angmax: 360.0,
1883 control: None,
1884 solution: None,
1885 uid: None,
1886 route: None,
1887 extras: device_extras(f, 2),
1889 })
1890}
1891
1892#[expect(clippy::too_many_arguments)]
1893fn read_transformer(
1894 l1: &[String],
1895 l2: &[String],
1896 l3: &[String],
1897 l4: &[String],
1898 raw_rev: u32,
1899 system_base: f64,
1900 bus_base_kv: &BTreeMap<BusId, f64>,
1901 warnings: &mut Vec<String>,
1902) -> Result<Branch> {
1903 let (cw, cz) = transformer_basis_codes(l1)?;
1913 let from = BusId(id_at(l1, 0, 0)?);
1914 let to = BusId(id_at(l1, 1, 0)?);
1915 let sbase = num_at(l2, 2, system_base)?;
1916 let label = transformer_label(l1);
1917 let (r, x) = convert_transformer_impedance(
1918 num_at(l2, 0, 0.0)?,
1919 num_at(l2, 1, 0.0)?,
1920 sbase,
1921 system_base,
1922 cz,
1923 &label,
1924 "1-2",
1925 warnings,
1926 );
1927 let tap = two_winding_tap(l1, l3, l4, from, to, cw, bus_base_kv, warnings)?;
1928 let modern = raw_rev >= 34;
1929 let (cod_i, cont_i, rma_i) = if modern { (15, 16, 18) } else { (6, 7, 8) };
1930 let cod = int_at(l3, cod_i, 0)?;
1931 let control = (cod != 0)
1932 .then(|| -> Result<TransformerControl> {
1933 let cont = id_at(l3, cont_i, 0)?;
1934 Ok(TransformerControl {
1935 mode: cod_to_mode(cod),
1936 controlled_bus: (cont != 0).then_some(BusId(cont)),
1937 tap_max: num_at(l3, rma_i, 1.1)?,
1938 tap_min: num_at(l3, rma_i + 1, 0.9)?,
1939 band_max: num_at(l3, rma_i + 2, 1.1)?,
1940 band_min: num_at(l3, rma_i + 3, 0.9)?,
1941 ntp: int_at(l3, rma_i + 4, 33)?.clamp(0, i64::from(u32::MAX)) as u32,
1942 mva_base: sbase,
1943 })
1944 })
1945 .transpose()?;
1946 let mag_g = if int_at(l1, 6, 1)? == 1 {
1947 num_at(l1, 7, 0.0)?
1948 } else {
1949 0.0
1950 };
1951 let mag_b = if int_at(l1, 6, 1)? == 1 {
1952 num_at(l1, 8, 0.0)?
1953 } else {
1954 0.0
1955 };
1956 Ok(Branch {
1957 from,
1958 to,
1959 r,
1960 x,
1961 b: mag_b,
1962 charging: Some(BranchCharging {
1963 g_fr: mag_g,
1964 b_fr: mag_b,
1965 g_to: 0.0,
1966 b_to: 0.0,
1967 }),
1968 rate_a: num_at(l3, 3, 0.0)?,
1969 rate_b: num_at(l3, 4, 0.0)?,
1970 rate_c: num_at(l3, 5, 0.0)?,
1971 rating_sets: read_extra_branch_ratings(l3, 3, modern)?,
1972 current_ratings: None,
1973 tap,
1974 shift: num_at(l3, 2, 0.0)?,
1975 in_service: on_at(l1, 11, true)?,
1976 angmin: -360.0,
1977 angmax: 360.0,
1978 control,
1979 solution: None,
1980 uid: None,
1981 route: None,
1982 extras: Extras::new(),
1983 })
1984}
1985
1986fn cod_to_mode(cod: i64) -> TransformerControlMode {
1990 match cod.unsigned_abs() {
1995 1 => TransformerControlMode::Voltage,
1996 2 => TransformerControlMode::ReactiveFlow,
1997 3 => TransformerControlMode::ActiveFlow,
1998 _ => TransformerControlMode::Fixed,
1999 }
2000}
2001
2002fn mode_to_cod(mode: TransformerControlMode) -> i64 {
2004 match mode {
2005 TransformerControlMode::Fixed => 0,
2006 TransformerControlMode::Voltage => 1,
2007 TransformerControlMode::ReactiveFlow => 2,
2008 TransformerControlMode::ActiveFlow => 3,
2009 }
2010}
2011
2012#[expect(clippy::too_many_arguments)]
2014fn read_transformer_3w(
2015 l1: &[String],
2016 l2: &[String],
2017 l3: &[String],
2018 l4: &[String],
2019 l5: &[String],
2020 system_base: f64,
2021 bus_base_kv: &BTreeMap<BusId, f64>,
2022 warnings: &mut Vec<String>,
2023) -> Result<Transformer3W> {
2024 let (cw, cz) = transformer_basis_codes(l1)?;
2028 let label = transformer_label(l1);
2029 let buses = [
2030 BusId(id_at(l1, 0, 0)?),
2031 BusId(id_at(l1, 1, 0)?),
2032 BusId(id_at(l1, 2, 0)?),
2033 ];
2034 let z = {
2035 let mut imp = |off: usize, pair: &str| -> Result<Impedance> {
2036 let sbase = num_at(l2, off + 2, system_base)?;
2037 let (r, x) = convert_transformer_impedance(
2038 num_at(l2, off, 0.0)?,
2039 num_at(l2, off + 1, 0.0)?,
2040 sbase,
2041 system_base,
2042 cz,
2043 &label,
2044 pair,
2045 warnings,
2046 );
2047 Ok(Impedance {
2048 r,
2049 x,
2050 base_mva: sbase,
2051 })
2052 };
2053 [imp(0, "1-2")?, imp(3, "2-3")?, imp(6, "3-1")?]
2054 };
2055 let windings = {
2056 let mut winding = |idx: usize, w: &[String]| -> Result<Winding> {
2057 let bus = buses[idx];
2058 let tap = winding_ratio(
2059 w,
2060 bus,
2061 cw,
2062 bus_base_kv,
2063 &label,
2064 match idx {
2065 0 => "winding 1",
2066 1 => "winding 2",
2067 _ => "winding 3",
2068 },
2069 warnings,
2070 )?;
2071 Ok(Winding {
2072 bus,
2073 tap,
2074 shift: num_at(w, 2, 0.0)?,
2075 nominal_kv: num_at(w, 1, 0.0)?,
2076 rate_a: num_at(w, 3, 0.0)?,
2077 rate_b: num_at(w, 4, 0.0)?,
2078 rate_c: num_at(w, 5, 0.0)?,
2079 })
2080 };
2081 [winding(0, l3)?, winding(1, l4)?, winding(2, l5)?]
2082 };
2083 Ok(Transformer3W {
2084 windings,
2085 z,
2086 star_vm: num_at(l2, 9, 1.0)?,
2087 star_va: num_at(l2, 10, 0.0)?,
2088 mag_g: num_at(l1, 7, 0.0)?,
2089 mag_b: num_at(l1, 8, 0.0)?,
2090 in_service: int_at(l1, 11, 1)? != 0,
2093 name: l1
2094 .get(10)
2095 .filter(|n| !n.is_empty())
2096 .map(|n| n.trim().to_string()),
2097 uid: None,
2098 extras: Extras::new(),
2099 })
2100}
2101
2102fn read_dc_line(l1: &[String], rect: &[String], inv: &[String]) -> Result<Hvdc> {
2112 let mdc = int_at(l1, 1, 1)?;
2113 let rdc = num_at(l1, 2, 0.0)?;
2114 let setvl = num_at(l1, 3, 0.0)?;
2115 let vschd = num_at(l1, 4, 0.0)?;
2116 let mut extras = Extras::new();
2117 if let Some(name) = l1.first().filter(|n| !n.is_empty()) {
2118 extras.insert("psse_dc_name".into(), Value::String(name.clone()));
2119 }
2120 extras.insert("psse_dc_mdc".into(), Value::from(mdc));
2121 extras.insert("psse_dc_rdc".into(), jnum(rdc));
2122 extras.insert("psse_dc_vschd".into(), jnum(vschd));
2123 extras.insert("psse_dc_control_tail".into(), tail_array(l1, 5));
2124 extras.insert("psse_dc_rectifier_tail".into(), tail_array(rect, 1));
2125 extras.insert("psse_dc_inverter_tail".into(), tail_array(inv, 1));
2126 Ok(Hvdc {
2127 from: BusId(id_at(rect, 0, 0)?),
2128 to: BusId(id_at(inv, 0, 0)?),
2129 in_service: mdc != 0,
2130 pf: setvl,
2131 pt: setvl,
2132 qf: 0.0,
2133 qt: 0.0,
2134 vf: 1.0,
2135 vt: 1.0,
2136 pmin: 0.0,
2137 pmax: setvl.abs(),
2138 qminf: 0.0,
2139 qmaxf: 0.0,
2140 qmint: 0.0,
2141 qmaxt: 0.0,
2142 loss0: 0.0,
2143 loss1: 0.0,
2144 cost: None,
2145 uid: None,
2146 extras,
2147 })
2148}
2149
2150fn tail_array(f: &[String], start: usize) -> Value {
2152 Value::Array(
2153 f.iter()
2154 .skip(start)
2155 .map(|s| Value::String(s.clone()))
2156 .collect(),
2157 )
2158}
2159
2160fn dc_str(extras: &Extras, key: &str) -> Option<String> {
2162 extras.get(key).and_then(Value::as_str).map(str::to_owned)
2163}
2164
2165fn dc_int(extras: &Extras, key: &str) -> Option<i64> {
2167 extras.get(key).and_then(Value::as_i64)
2168}
2169
2170fn dc_f64(extras: &Extras, key: &str) -> Option<f64> {
2172 extras.get(key).and_then(Value::as_f64)
2173}
2174
2175fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
2177 extras
2178 .get(key)
2179 .and_then(Value::as_f64)
2180 .filter(|v| v.is_finite())
2181}
2182
2183fn extra_i64(extras: &Extras, key: &str) -> Option<i64> {
2185 extras.get(key).and_then(Value::as_i64)
2186}
2187
2188fn same_load_total(a: f64, b: f64) -> bool {
2189 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
2190}
2191
2192fn typed_psse_scal(l: &Load, id: &str, warnings: &mut Vec<String>) -> Option<i64> {
2193 let Some(LoadVoltageModel::Zip {
2194 scaling: Some(scaling),
2195 ..
2196 }) = &l.voltage_model
2197 else {
2198 return None;
2199 };
2200 let scaling = *scaling;
2201 if !scaling.is_finite() {
2202 warnings.push(format!(
2203 "PSS/E load at bus {} id {id:?}: non-finite typed scaling has no SCAL value; used source/default SCAL",
2204 l.bus
2205 ));
2206 return None;
2207 }
2208 let rounded = scaling.round();
2209 if (scaling - rounded).abs() > 1e-9 || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
2210 warnings.push(format!(
2211 "PSS/E load at bus {} id {id:?}: non-integer typed scaling {scaling} has no SCAL value; used source/default SCAL",
2212 l.bus
2213 ));
2214 return None;
2215 }
2216 Some(rounded as i64)
2217}
2218
2219fn typed_psse_load_type(model: &LoadVoltageModel) -> Option<String> {
2220 match model {
2221 LoadVoltageModel::Zip {
2222 load_type: Some(load_type),
2223 ..
2224 } => Some(load_type.to_string()),
2225 _ => None,
2226 }
2227}
2228
2229fn load_components_for_write(
2230 l: &Load,
2231 id: &str,
2232 warnings: &mut Vec<String>,
2233) -> (f64, f64, f64, f64, f64, f64) {
2234 if let Some(LoadVoltageModel::Zip {
2235 p_constant_power,
2236 q_constant_power,
2237 p_constant_current,
2238 q_constant_current,
2239 p_constant_impedance,
2240 q_constant_impedance,
2241 v_nom,
2242 ..
2243 }) = &l.voltage_model
2244 {
2245 if same_load_total(
2246 p_constant_power + p_constant_current + p_constant_impedance,
2247 l.p,
2248 ) && same_load_total(
2249 q_constant_power + q_constant_current + q_constant_impedance,
2250 l.q,
2251 ) {
2252 if v_nom.is_some() {
2253 warnings.push(format!(
2254 "PSS/E load at bus {} id {id:?}: nominal voltage has no load record field; dropped",
2255 l.bus
2256 ));
2257 }
2258 return (
2259 *p_constant_power,
2260 *q_constant_power,
2261 *p_constant_current,
2262 *q_constant_current,
2263 *p_constant_impedance,
2264 *q_constant_impedance,
2265 );
2266 }
2267 warnings.push(format!(
2268 "PSS/E load at bus {} id {id:?}: stale voltage model components did not match \
2269 typed p/q; wrote typed p/q as constant power",
2270 l.bus
2271 ));
2272 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2273 }
2274 if matches!(l.voltage_model, Some(LoadVoltageModel::Exponential { .. })) {
2275 warnings.push(format!(
2276 "PSS/E load at bus {} id {id:?}: exponential voltage model has no load record fields; wrote typed p/q as constant power",
2277 l.bus
2278 ));
2279 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2280 }
2281
2282 let pl = extra_f64(&l.extras, "psse_pl").unwrap_or(l.p);
2283 let ql = extra_f64(&l.extras, "psse_ql").unwrap_or(l.q);
2284 let ip = extra_f64(&l.extras, "psse_ip").unwrap_or(0.0);
2285 let iq = extra_f64(&l.extras, "psse_iq").unwrap_or(0.0);
2286 let yp = extra_f64(&l.extras, "psse_yp").unwrap_or(0.0);
2287 let yq = extra_f64(&l.extras, "psse_yq").unwrap_or(0.0);
2288 let has_components = [
2289 "psse_pl", "psse_ql", "psse_ip", "psse_iq", "psse_yp", "psse_yq",
2290 ]
2291 .iter()
2292 .any(|key| l.extras.contains_key(*key));
2293 if has_components
2294 && (!same_load_total(pl + ip + yp, l.p) || !same_load_total(ql + iq + yq, l.q))
2295 {
2296 warnings.push(format!(
2297 "PSS/E load at bus {} id {id:?}: stale PL/QL/IP/IQ/YP/YQ extras did not match \
2298 typed p/q; wrote typed p/q as constant power",
2299 l.bus
2300 ));
2301 (l.p, l.q, 0.0, 0.0, 0.0, 0.0)
2302 } else {
2303 (pl, ql, ip, iq, yp, yq)
2304 }
2305}
2306
2307fn dc_tail(extras: &Extras, key: &str, default: &str) -> String {
2310 match extras.get(key).and_then(Value::as_array) {
2311 Some(arr) if !arr.is_empty() => arr
2312 .iter()
2313 .filter_map(Value::as_str)
2314 .map(|f| sanitize_quoted(f, NAME_FORBIDDEN, ' ').into_owned())
2319 .collect::<Vec<_>>()
2320 .join(", "),
2321 _ => default.to_string(),
2322 }
2323}
2324
2325#[cfg(test)]
2326mod tests {
2327 use super::*;
2328
2329 fn close(actual: f64, expected: f64) {
2330 assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2331 }
2332
2333 #[test]
2334 fn extreme_transformer_cod_does_not_overflow() {
2335 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2339CASE
2340COMMENT
23411,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
23422,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
23430 / END OF BUS DATA, BEGIN LOAD DATA
23440 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
23450 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
23460 / END OF GENERATOR DATA, BEGIN BRANCH DATA
23470 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
23481,2,0,'1 ',2,2,1,0,0,1,'xf',1
23490.01,0.10,50.0
2350241.5,230.0,0.0,100.0,90.0,80.0,-1e300,0,1.1,0.9,1.1,0.9,33
2351115.0,115.0
23520 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2353Q
2354";
2355 let parsed = crate::parse_str(raw, "psse").unwrap();
2356 let control = parsed.network.branches[0].control.as_ref().unwrap();
2357 assert_eq!(control.mode, TransformerControlMode::Fixed);
2358 }
2359
2360 fn test_bus(id: usize, kind: BusType) -> Bus {
2361 Bus {
2362 id: BusId(id),
2363 kind,
2364 vm: 1.0,
2365 va: 0.0,
2366 base_kv: 230.0,
2367 vmax: 1.1,
2368 vmin: 0.9,
2369 evhi: None,
2370 evlo: None,
2371 area: 1,
2372 zone: 1,
2373 name: None,
2374 uid: None,
2375 location: None,
2376 extras: Extras::default(),
2377 }
2378 }
2379
2380 fn branch_with_terminal_charging() -> Branch {
2381 Branch {
2382 from: BusId(1),
2383 to: BusId(2),
2384 r: 0.01,
2385 x: 0.1,
2386 b: 0.0,
2387 charging: Some(BranchCharging {
2388 g_fr: 0.01,
2389 b_fr: 0.02,
2390 g_to: 0.03,
2391 b_to: 0.05,
2392 }),
2393 rate_a: 100.0,
2394 rate_b: 110.0,
2395 rate_c: 120.0,
2396 rating_sets: Vec::new(),
2397 current_ratings: None,
2398 tap: 0.0,
2399 shift: 0.0,
2400 in_service: true,
2401 angmin: -360.0,
2402 angmax: 360.0,
2403 control: None,
2404 solution: None,
2405 uid: None,
2406 route: None,
2407 extras: Extras::default(),
2408 }
2409 }
2410
2411 fn transformer_with_terminal_charging(charging: BranchCharging) -> Branch {
2412 Branch {
2413 from: BusId(1),
2414 to: BusId(2),
2415 r: 0.01,
2416 x: 0.1,
2417 b: 0.0,
2418 charging: Some(charging),
2419 rate_a: 100.0,
2420 rate_b: 110.0,
2421 rate_c: 120.0,
2422 rating_sets: Vec::new(),
2423 current_ratings: None,
2424 tap: 1.05,
2425 shift: 0.0,
2426 in_service: true,
2427 angmin: -360.0,
2428 angmax: 360.0,
2429 control: None,
2430 solution: None,
2431 uid: None,
2432 route: None,
2433 extras: Extras::default(),
2434 }
2435 }
2436
2437 fn assert_terminal_charging_round_trip(text: &str) {
2438 let back = parse_psse(text).unwrap();
2439 let charging = back.branches[0].terminal_charging();
2440 close(charging.g_fr, 0.01);
2441 close(charging.b_fr, 0.02);
2442 close(charging.g_to, 0.03);
2443 close(charging.b_to, 0.05);
2444 close(back.branches[0].b, 0.07);
2445 }
2446
2447 #[test]
2448 fn branch_terminal_charging_writes_gi_bi_gj_bj() {
2449 let mut net = BalancedNetwork::in_memory(
2450 "terminal-shunts",
2451 100.0,
2452 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2453 Vec::new(),
2454 );
2455 net.branches.push(branch_with_terminal_charging());
2456
2457 let rev33 = write_psse(&net);
2458 assert!(rev33.warnings.is_empty(), "{:?}", rev33.warnings);
2459 assert_terminal_charging_round_trip(&rev33.text);
2460
2461 let rev35 = write_psse_rev(&net, 35);
2462 assert!(rev35.warnings.is_empty(), "{:?}", rev35.warnings);
2463 assert_terminal_charging_round_trip(&rev35.text);
2464 }
2465
2466 #[test]
2467 fn transformer_magnetizing_admittance_writes_mag1_mag2() {
2468 let mut net = BalancedNetwork::in_memory(
2469 "xfmr-mag",
2470 100.0,
2471 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2472 Vec::new(),
2473 );
2474 net.branches
2475 .push(transformer_with_terminal_charging(BranchCharging {
2476 g_fr: 0.01,
2477 b_fr: 0.02,
2478 g_to: 0.0,
2479 b_to: 0.0,
2480 }));
2481
2482 let conv = write_psse(&net);
2483 assert!(
2484 !conv
2485 .warnings
2486 .iter()
2487 .any(|w| w.contains("magnetizing admittance")),
2488 "{:?}",
2489 conv.warnings
2490 );
2491 let back = parse_psse(&conv.text).unwrap();
2492 let charging = back.branches[0].terminal_charging();
2493 close(charging.g_fr, 0.01);
2494 close(charging.b_fr, 0.02);
2495 close(charging.g_to, 0.0);
2496 close(charging.b_to, 0.0);
2497 close(back.branches[0].b, 0.02);
2498 }
2499
2500 #[test]
2501 fn transformer_to_side_terminal_admittance_warns_and_collapses_to_mag() {
2502 let mut net = BalancedNetwork::in_memory(
2503 "xfmr-mag-collapse",
2504 100.0,
2505 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2506 Vec::new(),
2507 );
2508 net.branches
2509 .push(transformer_with_terminal_charging(BranchCharging {
2510 g_fr: 0.01,
2511 b_fr: 0.02,
2512 g_to: 0.03,
2513 b_to: 0.05,
2514 }));
2515
2516 let conv = write_psse(&net);
2517 assert!(
2518 conv.warnings
2519 .iter()
2520 .any(|w| w.contains("magnetizing admittance")),
2521 "{:?}",
2522 conv.warnings
2523 );
2524 let back = parse_psse(&conv.text).unwrap();
2525 let charging = back.branches[0].terminal_charging();
2526 close(charging.g_fr, 0.04);
2527 close(charging.b_fr, 0.07);
2528 close(charging.g_to, 0.0);
2529 close(charging.b_to, 0.0);
2530 close(back.branches[0].b, 0.07);
2531 }
2532
2533 #[test]
2534 fn slash_inside_a_quoted_field_is_not_a_comment() {
2535 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2536CASE
2537COMMENT
25381,'A/B ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25390 / END OF BUS DATA, BEGIN LOAD DATA
2540Q
2541";
2542
2543 let net = parse_psse(raw).unwrap();
2544
2545 assert_eq!(net.buses.len(), 1);
2546 assert_eq!(net.buses[0].name.as_deref(), Some("A/B"));
2547 }
2548
2549 #[test]
2550 fn load_zip_components_are_typed_and_round_trip() {
2551 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2552CASE
2553COMMENT
25540 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
25551,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25562,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25570 / END OF BUS DATA, BEGIN LOAD DATA
25582,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,0,1,4.0,2.0,1,'industrial'
25590 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2560Q
2561";
2562 let mut warnings = Vec::new();
2563 let net =
2564 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
2565
2566 assert_eq!(net.loads.len(), 1);
2567 close(net.loads[0].p, 13.0);
2568 close(net.loads[0].q, 5.0);
2569 let Some(LoadVoltageModel::Zip {
2570 p_constant_power,
2571 q_constant_current,
2572 p_constant_impedance,
2573 ..
2574 }) = &net.loads[0].voltage_model
2575 else {
2576 panic!("missing typed ZIP load model");
2577 };
2578 close(*p_constant_power, 10.0);
2579 close(*q_constant_current, 0.5);
2580 close(*p_constant_impedance, 2.0);
2581 assert!(
2582 warnings.iter().any(|w| w.contains("interruptible/DG/flag")),
2583 "missing load option warning: {warnings:?}"
2584 );
2585
2586 let text = write_psse_rev(&net, 35).text;
2587 assert!(
2588 text.contains("10.0, 3.0, 1.0, 0.5, 2.0, 1.5"),
2589 "ZIP components were not replayed: {text}"
2590 );
2591 assert!(
2592 text.contains("4.0, 2.0, 1, 'industrial'"),
2593 "modern load tail was not replayed: {text}"
2594 );
2595 let net2 = parse_psse(&text).unwrap();
2596 close(net2.loads[0].p, 13.0);
2597 close(net2.loads[0].q, 5.0);
2598 }
2599
2600 #[test]
2601 fn tiny_nonzero_zip_components_are_preserved_as_typed_fields() {
2602 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2603CASE
2604COMMENT
26050 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
26061,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26072,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26080 / END OF BUS DATA, BEGIN LOAD DATA
26092,'L1',1,1,1,10.0,3.0,1e-20,0.0,0.0,0.0,1,1,0,0.0,0.0,0,''
26100 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2611Q
2612";
2613 let net = parse_psse(raw).unwrap();
2614 let Some(LoadVoltageModel::Zip {
2615 p_constant_current, ..
2616 }) = &net.loads[0].voltage_model
2617 else {
2618 panic!("tiny nonzero ZIP component was not typed");
2619 };
2620 assert_eq!(p_constant_current.to_bits(), 1.0e-20_f64.to_bits());
2621
2622 let matpower = crate::format::matpower::write_matpower_conversion(&net);
2623 assert!(
2624 matpower
2625 .warnings
2626 .iter()
2627 .any(|w| w.contains("voltage dependent load model")),
2628 "missing MATPOWER voltage model warning: {:?}",
2629 matpower.warnings
2630 );
2631 }
2632
2633 #[test]
2634 fn typed_psse_load_scaling_and_type_write_without_extras() {
2635 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2636CASE
2637COMMENT
26380 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
26391,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26402,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26410 / END OF BUS DATA, BEGIN LOAD DATA
26422,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,1,0,0.0,0.0,0,''
26430 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2644Q
2645";
2646 let mut net = parse_psse(raw).unwrap();
2647 let Some(LoadVoltageModel::Zip {
2648 scaling,
2649 load_type,
2650 v_nom,
2651 ..
2652 }) = &mut net.loads[0].voltage_model
2653 else {
2654 panic!("missing typed ZIP load model");
2655 };
2656 *scaling = Some(0.0);
2657 *load_type = Some(7);
2658 *v_nom = Some(230_000.0);
2659 net.loads[0].extras.remove("psse_scal");
2660 net.loads[0].extras.remove("psse_loadtype");
2661
2662 let conv = write_psse_rev(&net, 35);
2663
2664 assert!(
2665 conv.text.contains(", 1, 0, 0, 0.0, 0.0, 0, '7'"),
2666 "typed SCAL/LOADTYPE were not written: {}",
2667 conv.text
2668 );
2669 assert!(
2670 conv.warnings.iter().any(|w| w.contains("nominal voltage")),
2671 "missing nominal voltage warning: {:?}",
2672 conv.warnings
2673 );
2674 let rev33 = write_psse(&net);
2675 assert!(
2676 rev33
2677 .warnings
2678 .iter()
2679 .any(|w| w.contains("load type requires revision 35")),
2680 "missing rev33 load type warning: {:?}",
2681 rev33.warnings
2682 );
2683 let reparsed = parse_psse(&conv.text).unwrap();
2684 let Some(LoadVoltageModel::Zip {
2685 scaling, load_type, ..
2686 }) = &reparsed.loads[0].voltage_model
2687 else {
2688 panic!("missing reparsed ZIP load model");
2689 };
2690 assert_eq!(*scaling, Some(0.0));
2691 assert_eq!(*load_type, Some(7));
2692 }
2693
2694 #[test]
2695 fn mutated_load_does_not_replay_stale_psse_zip_extras() {
2696 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2697CASE
2698COMMENT
26990 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
27001,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27012,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27020 / END OF BUS DATA, BEGIN LOAD DATA
27032,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,0,1,4.0,2.0,1,'industrial'
27040 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2705Q
2706";
2707 let mut net = parse_psse(raw).unwrap();
2708 net.loads[0].p = 20.0;
2709 net.loads[0].q = 7.0;
2710
2711 let conv = write_psse_rev(&net, 35);
2712
2713 assert!(
2714 conv.text.contains("20.0, 7.0, 0.0, 0.0, 0.0, 0.0"),
2715 "typed p/q were not written as constant power: {}",
2716 conv.text
2717 );
2718 assert!(
2719 conv.warnings
2720 .iter()
2721 .any(|w| w.contains("stale voltage model components")),
2722 "missing stale voltage model warning: {:?}",
2723 conv.warnings
2724 );
2725 let reparsed = parse_psse(&conv.text).unwrap();
2726 close(reparsed.loads[0].p, 20.0);
2727 close(reparsed.loads[0].q, 7.0);
2728 }
2729
2730 #[test]
2731 fn transformer_continuation_rejects_section_terminator() {
2732 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2733CASE
2734COMMENT
27351,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27362,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27370 / END OF BUS DATA, BEGIN LOAD DATA
27380 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27390 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27400 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27410 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27421,2,0,'1 ',1,1,1,0,0,1,'xf'
27430 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2744Q
2745";
2746 let err = parse_psse(raw).unwrap_err().to_string();
2747 assert!(
2748 err.contains("transformer record ended before transformer impedance line"),
2749 "{err}"
2750 );
2751 }
2752
2753 #[test]
2754 fn transformer_impedance_line_can_start_with_zero_resistance() {
2755 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2756CASE
2757COMMENT
27581,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27592,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27600 / END OF BUS DATA, BEGIN LOAD DATA
27610 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27620 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27630 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27640 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27651,2,0,'1 ',1,1,1,0,0,1,'xf',1
27660,0.10,100.0
27671.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27681.0,230.0
27690 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2770Q
2771";
2772 let net = parse_psse(raw).unwrap();
2773
2774 assert_eq!(net.branches.len(), 1);
2775 close(net.branches[0].r, 0.0);
2776 close(net.branches[0].x, 0.10);
2777 }
2778
2779 #[test]
2780 fn transformer_non_integral_cz_is_a_hard_error() {
2781 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2785CASE
2786COMMENT
27871,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27882,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27890 / END OF BUS DATA, BEGIN LOAD DATA
27900 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27910 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27920 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27930 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27941,2,0,'1 ',1,2.9,1,0,0,1,'xf',1
27950,0.10,100.0
27961.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27971.0,230.0
27980 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2799Q
2800";
2801 let err = parse_psse(raw).unwrap_err().to_string();
2802 assert!(err.contains("field 5") && err.contains("2.9"), "{err}");
2803 }
2804
2805 #[test]
2806 fn non_unit_two_winding_transformer_bases_are_converted() {
2807 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2808CASE
2809COMMENT
28101,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28112,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28120 / END OF BUS DATA, BEGIN LOAD DATA
28130 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28140 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28150 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28160 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28171,2,0,'1 ',2,2,1,0,0,1,'xf',1
28180.01,0.10,50.0
2819241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2820115.0,115.0
28210 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2822Q
2823";
2824 let parsed = crate::parse_str(raw, "psse").unwrap();
2825 assert!(
2826 !parsed
2827 .warnings
2828 .iter()
2829 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2830 "unexpected transformer base warning: {:?}",
2831 parsed.warnings
2832 );
2833 let br = &parsed.network.branches[0];
2834 close(br.r, 0.02);
2835 close(br.x, 0.20);
2836 close(br.tap, 1.05);
2837 }
2838
2839 #[test]
2840 fn cz3_load_loss_and_cw3_nominal_voltage_are_converted() {
2841 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2842CASE
2843COMMENT
28441,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28452,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28460 / END OF BUS DATA, BEGIN LOAD DATA
28470 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28480 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28490 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28500 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28511,2,0,'1 ',3,3,1,0,0,1,'xf',1
2852250000.0,0.10,50.0
28531.05,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
28541.0,115.0
28550 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2856Q
2857";
2858 let parsed = crate::parse_str(raw, "psse").unwrap();
2859 assert!(
2860 !parsed
2861 .warnings
2862 .iter()
2863 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2864 "unexpected transformer base warning: {:?}",
2865 parsed.warnings
2866 );
2867 let br = &parsed.network.branches[0];
2868 close(br.r, 0.01);
2869 close(br.x, (0.10_f64 * 0.10 - 0.005_f64 * 0.005).sqrt() * 2.0);
2870 close(br.tap, 1.05);
2871 }
2872
2873 #[test]
2874 fn non_unit_three_winding_transformer_bases_are_converted() {
2875 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2876CASE
2877COMMENT
28781,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28792,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28803,'BUS3 ', 13.8,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28810 / END OF BUS DATA, BEGIN LOAD DATA
28820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28861,2,3,'1 ',2,2,1,0,0,1,'xf3',1
28870.01,0.10,50.0,0.02,0.20,100.0,0.03,0.30,200.0,1.0,0.0
2888241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2889115.0,115.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
289013.8,13.8,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
28910 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2892Q
2893";
2894 let parsed = crate::parse_str(raw, "psse").unwrap();
2895 assert!(
2896 !parsed
2897 .warnings
2898 .iter()
2899 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2900 "unexpected transformer base warning: {:?}",
2901 parsed.warnings
2902 );
2903 let t = &parsed.network.transformers_3w[0];
2904 close(t.z[0].r, 0.02);
2905 close(t.z[0].x, 0.20);
2906 close(t.z[1].r, 0.02);
2907 close(t.z[1].x, 0.20);
2908 close(t.z[2].r, 0.015);
2909 close(t.z[2].x, 0.15);
2910 close(t.windings[0].tap, 1.05);
2911 close(t.windings[1].tap, 1.0);
2912 close(t.windings[2].tap, 1.0);
2913 }
2914
2915 #[test]
2916 fn dc_continuation_rejects_section_terminator() {
2917 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2918CASE
2919COMMENT
29200 / END OF SYSTEM-WIDE DATA, BEGIN TWO-TERMINAL DC DATA
2921'DC1',1
29220 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
2923Q
2924";
2925 let err = parse_psse(raw).unwrap_err().to_string();
2926 assert!(
2927 err.contains("two-terminal DC record ended before rectifier line"),
2928 "{err}"
2929 );
2930 }
2931
2932 #[test]
2933 fn reads_comment_headers_system_wide_block_and_named_branch_records() {
2934 let raw = r#"@!IC, SBASE,REV,XFRRAT,NXFRAT,BASFRQ
29350, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2936
2937
2938GENERAL, THRSHZ=0.0002
2939RATING, 1, " ", " "
29400 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
2941@! I,'NAME ', BASKV, IDE,AREA,ZONE,OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO
29421,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
29432,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
29440 / END OF BUS DATA, BEGIN LOAD DATA
2945@! I,'ID',STAT,AREA,ZONE, PL, QL
29462,'1 ',1,1,1,10.0,5.0
29470 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
29480 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
2949@! I,'ID', PG, QG, QT, QB, VS, IREG, MBASE, ZR, ZX, RT, XT, GTAP,STAT, RMPCT, PT, PB
29501,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
29510 / END OF GENERATOR DATA, BEGIN BRANCH DATA
2952@! I, J,'CKT', R, X, B, 'N A M E' , RATE1, RATE2, RATE3, RATE4, RATE5, RATE6, RATE7, RATE8, RATE9, RATE10, RATE11, RATE12, GI, BI, GJ, BJ,STAT,MET, LEN
29531,2,'1 ',0.01,0.05,0.001,'named branch',100.0,90.0,80.0,70.0,0.0,60.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1,1,0.0
29540 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
29550 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2956Q
2957"#;
2958
2959 let mut net = parse_psse(raw).unwrap();
2960
2961 close(net.base_mva, 100.0);
2962 assert_eq!(net.buses.len(), 2);
2963 assert_eq!(net.loads.len(), 1);
2964 assert_eq!(net.generators.len(), 1);
2965 assert_eq!(net.branches.len(), 1);
2966 close(net.branches[0].rate_a, 100.0);
2967 assert_eq!(net.branches[0].rating_sets.len(), 2);
2968 assert_eq!(net.branches[0].rating_sets[0].name, "RATE4");
2969 close(net.branches[0].rating_sets[0].rate_mva, 70.0);
2970 assert_eq!(net.branches[0].rating_sets[1].name, "RATE6");
2971 close(net.branches[0].rating_sets[1].rate_mva, 60.0);
2972 assert!(net.branches[0].in_service);
2973
2974 net.source = None;
2975 let written = write_psse_rev(&net, 34);
2976 assert!(
2977 !written.warnings.iter().any(|w| w.contains("rating set")),
2978 "v34 should carry RATE4-RATE12, got {:?}",
2979 written.warnings
2980 );
2981 let back = parse_psse(&written.text).unwrap();
2982 assert_eq!(back.branches[0].rating_sets.len(), 2);
2983 assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
2984 close(back.branches[0].rating_sets[0].rate_mva, 70.0);
2985 assert_eq!(back.branches[0].rating_sets[1].name, "RATE6");
2986 close(back.branches[0].rating_sets[1].rate_mva, 60.0);
2987 }
2988
2989 #[test]
2990 fn v34_transformer_reads_float_k_and_modern_winding_columns() {
2991 let raw = r"0, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2997CASE
2998COMMENT
29990 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
30001,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30012,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30020 / END OF BUS DATA, BEGIN LOAD DATA
30030 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
30040 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
30050 / END OF GENERATOR DATA, BEGIN BRANCH DATA
30060 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
30071, 2, 0.00, '1', 1, 1, 1, 0.0, 0.0, 2, 'T1 ', 1, 1, 1.0, 0, 1, 0, 1, 0, 1, ' '
30080.01, 0.10, 100.0
30091.05, 0.0, 0.0, 100.0, 90.0, 80.0, 70.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 2, 0, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
30101.0, 0.0
30110 / END OF TRANSFORMER DATA, BEGIN AREA DATA
30121, 1, 0.0, 0.0, 'AREA '
3013Q
3014";
3015 let net = parse_psse(raw).unwrap();
3016 assert_eq!(net.branches.len(), 1, "K = 0.00 is a 2-winding record");
3017 assert!(net.transformers_3w.is_empty());
3018 assert_eq!(
3019 net.areas.len(),
3020 1,
3021 "the section after the transformer parsed"
3022 );
3023 let br = &net.branches[0];
3024 close(br.tap, 1.05);
3025 close(br.rate_a, 100.0);
3026 assert_eq!(br.rating_sets.len(), 1);
3027 assert_eq!(br.rating_sets[0].name, "RATE4");
3028 close(br.rating_sets[0].rate_mva, 70.0);
3029 let c = br.control.as_ref().expect("COD at 15 marks the control");
3030 assert_eq!(c.mode, TransformerControlMode::Voltage);
3031 assert_eq!(c.controlled_bus, Some(BusId(2)));
3032 close(c.tap_max, 1.08);
3033 close(c.tap_min, 0.92);
3034 close(c.band_max, 1.05);
3035 close(c.band_min, 0.98);
3036 assert_eq!(c.ntp, 17);
3037 }
3038
3039 #[test]
3040 fn v34_warns_when_custom_rating_name_is_emitted_as_rate_slot() {
3041 let mut net = BalancedNetwork::in_memory(
3042 "ratings",
3043 100.0,
3044 vec![
3045 Bus::new(BusId(1), BusType::Ref, 230.0),
3046 Bus::new(BusId(2), BusType::Pq, 230.0),
3047 ],
3048 Vec::new(),
3049 );
3050 let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.05);
3051 branch.rate_a = 100.0;
3052 branch
3053 .rating_sets
3054 .push(BranchRatingSet::new("emergency", 125.0));
3055 net.branches.push(branch);
3056
3057 let written = write_psse_rev(&net, 34);
3058
3059 assert!(
3060 written.warnings.iter().any(|w| {
3061 w.contains("rating set emergency=125")
3062 && w.contains("emitted as RATE4")
3063 && w.contains("names outside RATE4-RATE12 are not preserved")
3064 }),
3065 "missing rating rename warning: {:?}",
3066 written.warnings
3067 );
3068 let back = parse_psse(&written.text).unwrap();
3069 assert_eq!(back.branches[0].rating_sets.len(), 1);
3070 assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
3071 close(back.branches[0].rating_sets[0].rate_mva, 125.0);
3072 }
3073
3074 #[test]
3075 fn reads_start_of_section_markers_and_gen_alias() {
3076 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3077CASE
3078COMMENT
30791,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30802,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30810 / End of Bus Data, Start of Load Data
30822,'1 ',1,1,1,10.0,5.0
30830 / End of Load Data, Start of Fixed Shunt Data
30840 / End of Fixed Shunt Data, Start of Gen Data
30851,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
30860 / End of Gen Data, Start of Branch Data
30871,2,'1 ',0.01,0.05,0.001,100.0,90.0,80.0,0.0,0.0,0.0,0.0,1,1,0.0,1,1
30880 / End of Branch Data, Start of Transformer Data
30890 / End of Transformer Data, Start of Area Interchange Data
3090Q
3091";
3092
3093 let net = parse_psse(raw).unwrap();
3094
3095 assert_eq!(net.buses.len(), 2);
3096 assert_eq!(net.loads.len(), 1);
3097 assert_eq!(net.generators.len(), 1);
3098 assert_eq!(net.branches.len(), 1);
3099 }
3100
3101 #[test]
3102 fn v33_long_branch_with_blank_ratea_keeps_v33_columns() {
3103 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3104CASE
3105COMMENT
31061,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
31072,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
31080 / END OF BUS DATA, BEGIN LOAD DATA
31090 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31100 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31110 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31121,2,'1 ',0.01,0.05,0.001,,90.0,80.0,0.0,0.0,0.0,0.0,1,1,0.0,1,1.0,2,0.0,3,0.0,4,0.0
31130 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31140 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3115Q
3116";
3117
3118 let net = parse_psse(raw).unwrap();
3119
3120 assert_eq!(net.branches.len(), 1);
3121 close(net.branches[0].rate_a, 0.0);
3122 close(net.branches[0].rate_b, 90.0);
3123 close(net.branches[0].rate_c, 80.0);
3124 assert!(net.branches[0].in_service);
3125 }
3126
3127 #[test]
3128 fn captured_load_ids_round_trip_and_parallel_loads_stay_distinct() {
3129 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3130CASE
3131COMMENT
31321,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31332,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31340 / END OF BUS DATA, BEGIN LOAD DATA
31352,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
31362,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
31370 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31380 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31390 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31400 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31410 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3142Q
3143";
3144 let id = |l: &Load| {
3145 l.extras
3146 .get("id")
3147 .and_then(|v| v.as_str())
3148 .map(str::to_owned)
3149 };
3150 let net = parse_psse(raw).unwrap();
3151 assert_eq!(net.loads.len(), 2);
3152 assert_eq!(id(&net.loads[0]).as_deref(), Some("A"));
3153 assert_eq!(id(&net.loads[1]).as_deref(), Some("B"));
3154
3155 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3157 assert_eq!(id(&net2.loads[0]).as_deref(), Some("A"));
3158 assert_eq!(id(&net2.loads[1]).as_deref(), Some("B"));
3159
3160 let mut synth = net.clone();
3164 for l in &mut synth.loads {
3165 l.extras.remove("id");
3166 }
3167 let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3168 let ids: Vec<_> = net3.loads.iter().filter_map(&id).collect();
3169 assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3170 }
3171
3172 #[test]
3173 fn sanitized_load_ids_are_allocated_after_cleaning() {
3174 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3175CASE
3176COMMENT
31771,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31782,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31790 / END OF BUS DATA, BEGIN LOAD DATA
31802,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
31812,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
31820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31860 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3187Q
3188";
3189 let mut net = parse_psse(raw).unwrap();
3190 net.loads[0]
3191 .extras
3192 .insert("id".into(), Value::String("A/B".into()));
3193 net.loads[1]
3194 .extras
3195 .insert("id".into(), Value::String("A'B".into()));
3196
3197 let conv = write_psse(&net);
3198 let reparsed = parse_psse(&conv.text).unwrap();
3199 let ids: Vec<_> = reparsed
3200 .loads
3201 .iter()
3202 .filter_map(|l| l.extras.get("id").and_then(Value::as_str))
3203 .collect();
3204
3205 assert_eq!(ids, vec!["A B", "1"]);
3206 assert!(
3207 conv.warnings
3208 .iter()
3209 .any(|w| w.contains("2 quoted PSS/E field")),
3210 "missing sanitation warning: {:?}",
3211 conv.warnings
3212 );
3213 }
3214
3215 #[test]
3216 fn two_winding_transformer_charging_round_trips_via_mag2() {
3217 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3220CASE
3221COMMENT
32221,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
32232,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
32240 / END OF BUS DATA, BEGIN LOAD DATA
32250 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
32260 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
32270 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32280 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32291, 2, 0, '1', 1, 1, 1, 0, 0.04, 2, 'XF ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
32300.01, 0.10, 100.0
32311.025, 0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
32321.0, 0
32330 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3234Q
3235";
3236 let net = parse_psse(raw).unwrap();
3237 assert_eq!(net.branches.len(), 1);
3238 assert!(net.branches[0].is_transformer());
3239 close(net.branches[0].b, 0.04);
3240
3241 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3242 close(net2.branches[0].b, 0.04);
3243 }
3244
3245 #[test]
3246 fn parallel_branches_round_trip_and_stay_distinct() {
3247 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3250CASE
3251COMMENT
32521,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32532,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32540 / END OF BUS DATA, BEGIN LOAD DATA
32550 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
32560 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
32570 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32581,2,'1 ',0.01,0.05,0.001,0,0,0,0,0,0,0,1,1,0.0
32591,2,'2 ',0.02,0.06,0.002,0,0,0,0,0,0,0,1,1,0.0
32600 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32610 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3262Q
3263";
3264 let ckt = |b: &Branch| {
3265 b.extras
3266 .get("id")
3267 .and_then(|v| v.as_str())
3268 .map(str::to_owned)
3269 };
3270 let net = parse_psse(raw).unwrap();
3271 assert_eq!(net.branches.len(), 2);
3272 assert_eq!(ckt(&net.branches[0]).as_deref(), Some("1"));
3273 assert_eq!(ckt(&net.branches[1]).as_deref(), Some("2"));
3274
3275 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3277 assert_eq!(net2.branches.len(), 2);
3278 assert_eq!(ckt(&net2.branches[0]).as_deref(), Some("1"));
3279 assert_eq!(ckt(&net2.branches[1]).as_deref(), Some("2"));
3280
3281 let mut synth = net.clone();
3284 for b in &mut synth.branches {
3285 b.extras.remove("id");
3286 }
3287 let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3288 let ids: Vec<_> = net3.branches.iter().filter_map(&ckt).collect();
3289 assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3290 }
3291
3292 #[test]
3293 fn reads_and_writes_solver_params() {
3294 let raw = r"0, 100.00, 34, 0, 1, 60.00 / x
3295CASE
3296COMMENT
3297GENERAL, THRSHZ=0.0001
3298NEWTON, TOLN=0.1, ITMXN=25
3299SOLVER, ACTAPS=1, AREAIN=0, PHSHFT=1, DCTAPS=1, SWSHNT=0
33000 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
33011,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33020 / END OF BUS DATA, BEGIN LOAD DATA
3303Q
3304";
3305 let net = parse_psse(raw).unwrap();
3306 let sp = net.solver.as_ref().expect("solver params parsed");
3307 close(sp.zero_impedance_threshold.unwrap(), 0.0001);
3308 close(sp.newton_tolerance.unwrap(), 0.1);
3309 assert_eq!(sp.max_iterations, Some(25));
3310 assert_eq!(sp.adjust_taps, Some(true));
3311 assert_eq!(sp.adjust_area_interchange, Some(false));
3312 assert_eq!(sp.adjust_phase_shift, Some(true));
3313 assert_eq!(sp.adjust_switched_shunt, Some(false));
3314
3315 let net2 = parse_psse(&write_psse_rev(&net, 34).text).unwrap();
3317 let sp2 = net2
3318 .solver
3319 .as_ref()
3320 .expect("solver params survive the write");
3321 close(sp2.newton_tolerance.unwrap(), 0.1);
3322 assert_eq!(sp2.max_iterations, Some(25));
3323 assert_eq!(sp2.adjust_taps, Some(true));
3324 assert_eq!(sp2.adjust_area_interchange, Some(false));
3325 }
3326
3327 #[test]
3328 fn reads_and_writes_area_records() {
3329 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3330CASE
3331COMMENT
33321,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33335,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33340 / END OF BUS DATA, BEGIN LOAD DATA
33350 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33360 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33370 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33380 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33390 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33401, 5, 100.0, 10.0, 'AREA-ONE '
33410 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3342Q
3343";
3344 let net = parse_psse(raw).unwrap();
3345 assert_eq!(net.areas.len(), 1, "the area record was read");
3346 let a = &net.areas[0];
3347 assert_eq!(a.number, 1);
3348 assert_eq!(a.slack_bus, Some(BusId(5)));
3349 close(a.net_interchange, 100.0);
3350 close(a.tolerance, 10.0);
3351 assert_eq!(a.name.as_deref(), Some("AREA-ONE"));
3352
3353 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3355 assert_eq!(net2.areas.len(), 1);
3356 let a2 = &net2.areas[0];
3357 assert_eq!(a2.number, 1);
3358 assert_eq!(a2.slack_bus, Some(BusId(5)));
3359 close(a2.net_interchange, 100.0);
3360 assert_eq!(a2.name.as_deref(), Some("AREA-ONE"));
3361 }
3362
3363 #[test]
3364 fn reads_and_writes_a_switched_shunt() {
3365 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3366CASE
3367COMMENT
33681,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33693,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33707,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33710 / END OF BUS DATA, BEGIN LOAD DATA
33720 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33730 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33740 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33750 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33760 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33770 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
33783, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
33790 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3380Q
3381";
3382 let net = parse_psse(raw).unwrap();
3383 assert_eq!(net.shunts.len(), 1);
3384 let sh = &net.shunts[0];
3385 assert_eq!(sh.bus, BusId(3));
3386 close(sh.b, 19.0);
3387 let c = sh.control.as_ref().expect("switched-shunt control parsed");
3388 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3389 close(c.vhigh, 1.05);
3390 close(c.vlow, 0.95);
3391 assert_eq!(c.control_bus, Some(BusId(7)));
3392 close(c.rmpct, 100.0);
3393 assert_eq!(c.blocks.len(), 2);
3394 assert_eq!(c.blocks[0].steps, 2);
3395 close(c.blocks[0].b, 25.0);
3396 assert_eq!(c.blocks[1].steps, 1);
3397 close(c.blocks[1].b, 50.0);
3398
3399 let text = write_psse(&net).text;
3401 assert!(text.contains("BEGIN SWITCHED SHUNT DATA"));
3402 let net2 = parse_psse(&text).unwrap();
3403 assert_eq!(net2.shunts.len(), 1);
3404 let c2 = net2.shunts[0]
3405 .control
3406 .as_ref()
3407 .expect("control survives the write");
3408 assert_eq!(c2.mode, SwitchedShuntMode::Discrete);
3409 assert_eq!(c2.control_bus, Some(BusId(7)));
3410 assert_eq!(c2.blocks.len(), 2);
3411 close(c2.blocks[0].b, 25.0);
3412 close(net2.shunts[0].b, 19.0);
3413 }
3414
3415 #[test]
3416 fn v35_switched_shunt_write_round_trips_through_the_id_column() {
3417 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3422CASE
3423COMMENT
34243,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34257,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34260 / END OF BUS DATA, BEGIN LOAD DATA
34270 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34280 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34290 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34300 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34310 / END OF TRANSFORMER DATA, BEGIN AREA DATA
34320 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
34333, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
34340 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3435Q
3436";
3437 let net = parse_psse(raw).unwrap();
3438 let text = write_psse_rev(&net, 35).text;
3439 let net2 = parse_psse(&text).unwrap();
3440 assert_eq!(net2.shunts.len(), 1);
3441 let sh = &net2.shunts[0];
3442 assert_eq!(sh.bus, BusId(3));
3443 close(sh.b, 19.0);
3444 let c = sh
3445 .control
3446 .as_ref()
3447 .expect("v35 switched-shunt control survives the write");
3448 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3449 close(c.vhigh, 1.05);
3450 close(c.vlow, 0.95);
3451 assert_eq!(c.control_bus, Some(BusId(7)));
3452 close(c.rmpct, 100.0);
3453 assert_eq!(c.blocks.len(), 2);
3454 close(c.blocks[0].b, 25.0);
3455 close(c.blocks[1].b, 50.0);
3456 }
3457
3458 #[test]
3459 fn reads_and_writes_a_generator_remote_regulated_bus() {
3460 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3461CASE
3462COMMENT
34631,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34643,'B3 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34657,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34660 / END OF BUS DATA, BEGIN LOAD DATA
34670 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34680 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34693,'1', 50.0, 5.0, 30.0, -20.0, 1.02, 7, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 80.0, 0.0, 1, 1
34701,'1', 10.0, 0.0, 10.0, -10.0, 1.0, 0, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 50.0, 0.0, 1, 1
34710 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34720 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34730 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3474Q
3475";
3476 let net = parse_psse(raw).unwrap();
3477 assert_eq!(net.generators.len(), 2);
3478 let g3 = net.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3479 assert_eq!(
3480 g3.regulated_bus,
3481 Some(BusId(7)),
3482 "IREG names the remote regulated bus"
3483 );
3484 let g1 = net.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3486 assert_eq!(g1.regulated_bus, None);
3487
3488 let text = write_psse(&net).text;
3490 let net2 = parse_psse(&text).unwrap();
3491 let g3b = net2.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3492 assert_eq!(g3b.regulated_bus, Some(BusId(7)));
3493 let g1b = net2.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3494 assert_eq!(g1b.regulated_bus, None);
3495 }
3496
3497 #[test]
3498 fn reads_a_v35_generator_record_with_nreg() {
3499 let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3504CASE
3505COMMENT
35061,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35070 / END OF BUS DATA, BEGIN LOAD DATA
35080 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35090 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35101,'1 ',50.0,5.0,20.0,-10.0,1.0,0,2,900.0,0.0,1.0,0.0,0.0,1.0,0,100.0,80.0,10.0,0.0,1,1.0
35110 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35120 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35130 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3514Q
3515";
3516 let net = parse_psse(raw).unwrap();
3517 assert_eq!(net.generators.len(), 1);
3518 let g = &net.generators[0];
3519 close(g.mbase, 900.0);
3520 assert!(!g.in_service, "STAT = 0 at the shifted index");
3521 close(g.pmax, 80.0);
3522 close(g.pmin, 10.0);
3523 assert_eq!(g.regulated_bus, None, "IREG stays at field 7");
3524
3525 let net2 = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
3527 let g2 = &net2.generators[0];
3528 close(g2.mbase, 900.0);
3529 assert!(!g2.in_service);
3530 close(g2.pmax, 80.0);
3531 close(g2.pmin, 10.0);
3532 }
3533
3534 #[test]
3535 fn stale_control_pointers_warn_and_drop() {
3536 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3537CASE
3538COMMENT
35391,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35402,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35410 / END OF BUS DATA, BEGIN LOAD DATA
35420 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35430 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35441,'1', 50.0, 5.0, 30.0, -20.0, 1.02, 99, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 80.0, 0.0, 1, 1
35450 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35460 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35471, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
35480.01, 0.10, 100.0
35491.025, 0, 2.5, 100.0, 90.0, 80.0, 1, 98, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
35501.0, 0
35510 / END OF TRANSFORMER DATA, BEGIN AREA DATA
35521, 97, 0.0, 0.0, 'AREA '
35530 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
35540 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
35550 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
35560 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
35570 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
35580 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
35590 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
35600 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
35610 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA
35620 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA
35632, 2, 0, 1, 1.05, 0.95, 96, 100.0, '', 19.0, 2, 25.0
35640 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3565Q
3566";
3567 let mut warnings = Vec::new();
3568 let net =
3569 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3570
3571 assert_eq!(net.generators[0].regulated_bus, None);
3572 assert_eq!(
3573 net.branches[0]
3574 .control
3575 .as_ref()
3576 .and_then(|c| c.controlled_bus),
3577 None
3578 );
3579 assert_eq!(
3580 net.shunts[0].control.as_ref().and_then(|c| c.control_bus),
3581 None
3582 );
3583 assert_eq!(net.areas[0].slack_bus, None);
3584 assert!(
3585 warnings.iter().any(|w| w.contains("GENERATOR DATA")
3586 && w.contains("IREG")
3587 && w.contains("missing bus id 99")),
3588 "missing IREG warning: {warnings:?}"
3589 );
3590 assert!(
3591 warnings.iter().any(|w| w.contains("TRANSFORMER DATA")
3592 && w.contains("CONT")
3593 && w.contains("missing bus id 98")),
3594 "missing CONT warning: {warnings:?}"
3595 );
3596 assert!(
3597 warnings.iter().any(|w| w.contains("SWITCHED SHUNT DATA")
3598 && w.contains("SWREM")
3599 && w.contains("missing bus id 96")),
3600 "missing SWREM warning: {warnings:?}"
3601 );
3602 assert!(
3603 warnings.iter().any(|w| w.contains("AREA DATA")
3604 && w.contains("ISW")
3605 && w.contains("missing bus id 97")),
3606 "missing ISW warning: {warnings:?}"
3607 );
3608 }
3609
3610 #[test]
3611 fn truncated_transformer_continuation_names_expected_line() {
3612 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3613CASE
3614COMMENT
36151,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36162,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36170 / END OF BUS DATA, BEGIN LOAD DATA
36180 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36190 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36200 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36210 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36221, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
36230 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3624Q
3625";
3626 let err = parse_psse(raw).unwrap_err().to_string();
3627 assert!(
3628 err.contains("transformer record ended before transformer impedance line"),
3629 "got {err}"
3630 );
3631 }
3632
3633 #[test]
3634 fn unmodeled_section_counts_skip_bare_terminators() {
3635 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3636CASE
3637COMMENT
36381,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36390 / END OF BUS DATA, BEGIN LOAD DATA
36400 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36410 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36420 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36430 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36440 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36450 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
36460 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3647'VSC1', 1
36482, 3
36490
36500 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
3651Q
3652";
3653 let mut warnings = Vec::new();
3654 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3655 assert!(
3656 warnings
3657 .iter()
3658 .any(|w| w.contains("VSC DC LINE section (2 record line(s))")),
3659 "bare terminator should not be counted as skipped data: {warnings:?}"
3660 );
3661 }
3662
3663 #[test]
3664 fn reads_a_v35_switched_shunt_with_an_id_column() {
3665 let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3670CASE
3671COMMENT
36725,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36737,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36740 / END OF BUS DATA, BEGIN LOAD DATA
36750 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36760 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36770 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36780 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36790 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36800 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
36815,'1 ',2,0,1,1.05,0.95,7,3,80.0,'',19.0,1,2,25.0,0,1,50.0
36820 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3683Q
3684";
3685 let net = parse_psse(raw).unwrap();
3686 assert_eq!(net.shunts.len(), 1);
3687 let sh = &net.shunts[0];
3688 assert_eq!(sh.bus, BusId(5));
3689 close(sh.b, 19.0);
3690 assert!(sh.in_service);
3691 let c = sh.control.as_ref().expect("switched-shunt control parsed");
3692 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3693 close(c.vhigh, 1.05);
3694 close(c.vlow, 0.95);
3695 assert_eq!(
3696 c.control_bus,
3697 Some(BusId(7)),
3698 "SWREG at field 7, not NREG at 8"
3699 );
3700 close(c.rmpct, 80.0);
3701 assert_eq!(c.blocks.len(), 2);
3703 assert_eq!(c.blocks[0].steps, 2);
3704 close(c.blocks[0].b, 25.0);
3705 assert_eq!(c.blocks[1].steps, 1);
3706 close(c.blocks[1].b, 50.0);
3707 }
3708
3709 #[test]
3710 fn reads_and_writes_a_two_terminal_dc_line() {
3711 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3712CASE
3713COMMENT
37141,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37154,'B4 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37165,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37170 / END OF BUS DATA, BEGIN LOAD DATA
37180 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37190 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37200 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37210 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37220 / END OF TRANSFORMER DATA, BEGIN AREA DATA
37230 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3724'DCLINE1', 1, 2.5, 350.0, 500.0, 0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0
37254, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0
37265, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0
37270 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3728Q
3729";
3730 let net = parse_psse(raw).unwrap();
3731 assert_eq!(net.hvdc.len(), 1, "the two-terminal DC line was read");
3732 let dc = &net.hvdc[0];
3733 assert_eq!(dc.from, BusId(4), "rectifier bus is the from end");
3734 assert_eq!(dc.to, BusId(5), "inverter bus is the to end");
3735 assert!(dc.in_service);
3736 close(dc.pf, 350.0);
3737 close(dc.pt, 350.0);
3738
3739 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3741 assert_eq!(net2.hvdc.len(), 1, "the DC line survives the write");
3742 let dc2 = &net2.hvdc[0];
3743 assert_eq!(dc2.from, BusId(4));
3744 assert_eq!(dc2.to, BusId(5));
3745 assert!(dc2.in_service);
3746 close(dc2.pf, 350.0);
3747 }
3748
3749 #[test]
3750 fn reads_and_writes_a_regulating_transformer_control() {
3751 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3752CASE
3753COMMENT
37541,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37552,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37563,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37570 / END OF BUS DATA, BEGIN LOAD DATA
37580 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37590 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37600 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37610 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37621, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
37630.01, 0.10, 100.0
37641.025, 0, 2.5, 100.0, 90.0, 80.0, 1, 3, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
37651.0, 0
37660 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3767Q
3768";
3769 let net = parse_psse(raw).unwrap();
3770 assert_eq!(net.branches.len(), 1);
3771 let c = net.branches[0].control.as_ref().expect("control parsed");
3772 assert_eq!(c.mode, TransformerControlMode::Voltage);
3773 assert_eq!(c.controlled_bus, Some(BusId(3)));
3774 close(c.tap_max, 1.08);
3775 close(c.tap_min, 0.92);
3776 close(c.band_min, 0.98);
3777 assert_eq!(c.ntp, 17);
3778 close(c.mva_base, 100.0);
3779
3780 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3782 let c2 = net2.branches[0].control.as_ref().expect("control survives");
3783 assert_eq!(c2.mode, TransformerControlMode::Voltage);
3784 assert_eq!(c2.controlled_bus, Some(BusId(3)));
3785 close(c2.tap_max, 1.08);
3786 assert_eq!(c2.ntp, 17);
3787 close(net2.branches[0].tap, 1.025);
3788 close(net2.branches[0].shift, 2.5);
3789 }
3790
3791 #[test]
3792 fn reads_and_writes_a_three_winding_transformer() {
3793 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3794CASE
3795COMMENT
37961,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37972,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37983,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37990 / END OF BUS DATA, BEGIN LOAD DATA
38000 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38010 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38020 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38030 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38041, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
38050.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
38061.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38071.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38080.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38090 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3810Q
3811";
3812 let net = parse_psse(raw).unwrap();
3813 assert_eq!(
3814 net.transformers_3w.len(),
3815 1,
3816 "the 3-winding record was read"
3817 );
3818 assert!(net.branches.is_empty(), "a 3W is not folded into branches");
3819 let t = &net.transformers_3w[0];
3820 assert_eq!(
3821 [t.windings[0].bus, t.windings[1].bus, t.windings[2].bus],
3822 [BusId(1), BusId(2), BusId(3)]
3823 );
3824 close(t.z[0].r, 0.01);
3825 close(t.z[2].x, 0.30);
3826 close(t.windings[0].rate_a, 100.0);
3827 close(t.windings[1].tap, 1.025);
3828 close(t.windings[2].shift, 30.0);
3829 close(t.star_vm, 0.98);
3830 close(t.star_va, -1.5);
3831
3832 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3834 assert_eq!(net2.transformers_3w.len(), 1);
3835 assert!(net2.branches.is_empty());
3836 let t2 = &net2.transformers_3w[0];
3837 close(t2.z[1].x, 0.20);
3838 close(t2.windings[2].tap, 0.95);
3839 close(t2.star_va, -1.5);
3840 assert_eq!(t2.name.as_deref(), Some("T3W"));
3841 }
3842
3843 #[test]
3844 fn three_winding_cross_format_warns_and_survives_normalization() {
3845 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3848CASE
3849COMMENT
38501,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38512,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38523,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38530 / END OF BUS DATA, BEGIN LOAD DATA
38540 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38550 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38561,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
38570 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38580 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38591, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
38600.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
38611.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38621.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38630.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38640 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3865Q
3866";
3867 let net = parse_psse(raw).unwrap();
3868 assert_eq!(net.transformers_3w.len(), 1);
3869
3870 let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3873 assert!(
3874 mpc.warnings.iter().any(|w| w.contains("3-winding")),
3875 "MATPOWER write must warn on the dropped 3-winding transformer, got {:?}",
3876 mpc.warnings
3877 );
3878
3879 let norm = net.to_normalized().unwrap();
3881 assert_eq!(norm.transformers_3w.len(), 1, "to_normalized keeps the 3W");
3882 norm.validate().unwrap();
3883 }
3884
3885 #[test]
3886 fn writing_a_different_revision_re_emits_instead_of_echoing() {
3887 let raw = "0, 100.00, 33, 0, 0, 60.00 / x
3890CASE
3891COMMENT
38921,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
38930 / END OF BUS DATA, BEGIN LOAD DATA
38940 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38950 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38960 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38970 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38980 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3899Q
3900";
3901 let parsed = crate::parse_str(raw, "psse").unwrap();
3902 let same = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 33 }).unwrap();
3903 assert_eq!(same.text, raw, "same revision echoes the retained source");
3904 let v34 = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 34 }).unwrap();
3905 assert_ne!(v34.text, raw, "a different revision must re-emit, not echo");
3906 assert!(
3907 v34.text.contains("END OF SYSTEM-WIDE DATA"),
3908 "v34 output carries the system-wide marker, got:\n{}",
3909 v34.text
3910 );
3911 }
3912
3913 #[test]
3914 fn warns_on_a_nonempty_unmodeled_section() {
3915 let raw = "0, 100.00, 34, 0, 0, 60.00 / x
3918CASE
3919COMMENT
39201,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39210 / END OF BUS DATA, BEGIN LOAD DATA
39220 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39230 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39240 / END OF GENERATOR DATA, BEGIN BRANCH DATA
39250 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39260 / END OF TRANSFORMER DATA, BEGIN AREA DATA
39270 / END OF AREA DATA, BEGIN SUBSTATION DATA
39281, 'SUB1', 21.3, -157.8, 0.001
39290 / END OF SUBSTATION DATA, BEGIN GNE DEVICE DATA
3930Q
3931";
3932 let parsed = crate::parse_str(raw, "psse").unwrap();
3933 assert!(
3934 parsed
3935 .warnings
3936 .iter()
3937 .any(|w| w.contains("SUBSTATION") && w.contains("not modeled")),
3938 "an unmodeled substation section must be reported, got {:?}",
3939 parsed.warnings
3940 );
3941 }
3942
3943 #[test]
3944 fn reads_writes_and_drops_an_emergency_voltage_band() {
3945 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3948CASE
3949COMMENT
39501,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.2,0.8
39512,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39520 / END OF BUS DATA, BEGIN LOAD DATA
39530 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39540 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39551,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
39560 / END OF GENERATOR DATA, BEGIN BRANCH DATA
39570 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39580 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3959Q
3960";
3961 let net = parse_psse(raw).unwrap();
3962 let b1 = net.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3963 assert!(
3964 b1.evhi.is_some() && b1.evlo.is_some(),
3965 "distinct band typed"
3966 );
3967 close(b1.evhi.unwrap(), 1.2);
3968 close(b1.evlo.unwrap(), 0.8);
3969 let b2 = net.buses.iter().find(|b| b.id == BusId(2)).unwrap();
3970 assert!(
3971 b2.evhi.is_none() && b2.evlo.is_none(),
3972 "an emergency band equal to the normal band stays None"
3973 );
3974
3975 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3977 let r1 = net2.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3978 close(r1.evhi.unwrap(), 1.2);
3979 close(r1.evlo.unwrap(), 0.8);
3980
3981 let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3983 assert!(
3984 mpc.warnings
3985 .iter()
3986 .any(|w| w.contains("emergency voltage band")),
3987 "MATPOWER write must warn on the dropped emergency band, got {:?}",
3988 mpc.warnings
3989 );
3990 }
3991
3992 #[test]
3993 fn a_case_name_with_a_terminator_cannot_forge_a_bus_record() {
3994 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3999CASE
4000COMMENT
40011,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40020 / END OF BUS DATA, BEGIN LOAD DATA
40030 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40040 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40050 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40060 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40070 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4008Q
4009";
4010 let mut net = parse_psse(raw).unwrap();
4011 net.name =
4012 "A\n42, 'INJECTED ', 500.0, 2, 9, 9, 1, 1.0, 0.0, 1.1, 0.9, 1.1, 0.9".to_owned();
4013 net.source = None; let text = write_psse(&net).text;
4015 let back = parse_psse(&text).unwrap();
4016 assert_eq!(back.buses.len(), 1, "forged bus record in:\n{text}");
4017 }
4018
4019 #[test]
4020 fn writes_v34_v35_layouts_that_round_trip() {
4021 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
4022CASE
4023COMMENT
40241,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40252,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40260 / END OF BUS DATA, BEGIN LOAD DATA
40272,'1',1,1,1,10.0,5.0,0,0,0,0,1,1,0
40280 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40290 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40300 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40311,2,'1 ',0.01,0.05,0.001,111.0,90.0,80.0,0,0,0,0,1,1,0,1,1
40320 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40330 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4034Q
4035";
4036 let net = parse_psse(raw).unwrap();
4037
4038 for rev in [34u32, 35] {
4039 let text = write_psse_rev(&net, rev).text;
4040 assert!(
4042 text.contains("END OF SYSTEM-WIDE DATA, BEGIN BUS DATA"),
4043 "rev {rev} missing the system-wide marker"
4044 );
4045 let header = text.lines().next().unwrap();
4046 assert!(header.contains(&format!(", {rev}, ")), "header {header:?}");
4047 let branch = text.lines().find(|l| l.starts_with("1, 2, '1'")).unwrap();
4049 assert!(
4050 branch.split(',').count() >= 24,
4051 "rev {rev} branch is not the named layout: {branch:?}"
4052 );
4053
4054 let back = parse_psse(&text).unwrap();
4055 assert_eq!(back.buses.len(), 2);
4056 assert_eq!(back.loads.len(), 1);
4057 assert_eq!(back.branches.len(), 1);
4058 close(back.branches[0].rate_a, 111.0);
4059 close(back.loads[0].p, 10.0);
4060 assert!(back.branches[0].in_service);
4061 }
4062
4063 assert!(
4065 write_psse_rev(&net, 35).text.contains(", ''"),
4066 "v35 load should carry a LOADTYPE field"
4067 );
4068 }
4069
4070 #[test]
4071 fn writer_sanitizes_bus_names_that_would_corrupt_a_record() {
4072 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
4077CASE
4078COMMENT
40791,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
40802,'BUS2 ', 138.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
40810 / END OF BUS DATA, BEGIN LOAD DATA
40820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40860 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4087Q
4088";
4089 let mut net = parse_psse(raw).unwrap();
4090 net.buses[0].name = Some("O'Brien/X".to_string());
4091
4092 let conv = write_psse(&net);
4093 let reparsed = parse_psse(&conv.text).unwrap();
4094
4095 assert_eq!(reparsed.buses.len(), 2);
4096 close(reparsed.buses[0].base_kv, 230.0);
4097 close(reparsed.buses[1].base_kv, 138.0);
4098 let name = reparsed.buses[0].name.as_deref().unwrap();
4099 assert!(!name.contains('\'') && !name.contains('/'), "got {name:?}");
4100 assert!(
4101 conv.warnings
4102 .iter()
4103 .any(|w| w.contains("quoted PSS/E field")),
4104 "expected a sanitization warning, got {:?}",
4105 conv.warnings
4106 );
4107 }
4108
4109 #[test]
4110 fn malformed_first_bus_id_is_not_treated_as_system_wide_data() {
4111 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic malformed export
4112CASE
4113COMMENT
4114BAD,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
41150 / END OF BUS DATA, BEGIN LOAD DATA
4116Q
4117";
4118
4119 let err = parse_psse(raw).unwrap_err();
4120
4121 assert!(
4122 err.to_string().contains("bus record missing numeric id"),
4123 "malformed bus id should be reported directly: {err}"
4124 );
4125 }
4126}