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, Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras, Generator, Hvdc,
30 Impedance, Load, LoadVoltageModel, Network, Shunt, ShuntBlock, SolverParams, SourceFormat,
31 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: &Network, 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: &Network) -> Conversion {
146 write_psse_rev(net, REV)
147}
148
149#[must_use]
161#[expect(clippy::too_many_lines)]
164pub fn write_psse_rev(net: &Network, 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 _ = writeln!(
200 s,
201 "0, {}, {rev}, 0, {}, {} / powerio export: {}",
202 net.base_mva,
203 i32::from(modern),
204 num(net.base_frequency),
205 net.name
206 );
207 let _ = writeln!(s, "{}", net.name);
208 let _ = writeln!(s);
209 if modern {
210 if let Some(sp) = &net.solver {
213 if let Some(t) = sp.zero_impedance_threshold {
214 let _ = writeln!(s, "GENERAL, THRSHZ={}", num(t));
215 }
216 let mut newton = Vec::new();
217 if let Some(t) = sp.newton_tolerance {
218 newton.push(format!("TOLN={}", num(t)));
219 }
220 if let Some(n) = sp.max_iterations {
221 newton.push(format!("ITMXN={n}"));
222 }
223 if !newton.is_empty() {
224 let _ = writeln!(s, "NEWTON, {}", newton.join(", "));
225 }
226 let flags: Vec<String> = [
227 ("ACTAPS", sp.adjust_taps),
228 ("AREAIN", sp.adjust_area_interchange),
229 ("PHSHFT", sp.adjust_phase_shift),
230 ("DCTAPS", sp.adjust_dc_taps),
231 ("SWSHNT", sp.adjust_switched_shunt),
232 ]
233 .into_iter()
234 .filter_map(|(name, v)| v.map(|b| format!("{name}={}", i32::from(b))))
235 .collect();
236 if !flags.is_empty() {
237 let _ = writeln!(s, "SOLVER, {}", flags.join(", "));
238 }
239 }
240 let _ = writeln!(s, "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA");
241 }
242
243 let mut bus_area: BTreeMap<BusId, (usize, usize)> = BTreeMap::new();
245 for b in &net.buses {
246 bus_area.insert(b.id, (b.area, b.zone));
247 let raw_name = b.name.as_deref().unwrap_or("");
248 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
249 if matches!(name, std::borrow::Cow::Owned(_)) {
250 sanitized_quoted += 1;
251 }
252 let _ = writeln!(
255 s,
256 "{}, '{:<12}', {}, {}, {}, {}, 1, {}, {}, {}, {}, {}, {}",
257 b.id,
258 name,
259 num(b.base_kv),
260 ide(b.kind),
261 b.area,
262 b.zone,
263 num(b.vm),
264 num(b.va),
265 num(b.vmax),
266 num(b.vmin),
267 num(b.evhi.unwrap_or(b.vmax)),
268 num(b.evlo.unwrap_or(b.vmin))
269 );
270 }
271 let _ = writeln!(s, "0 / END OF BUS DATA, BEGIN LOAD DATA");
272
273 let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
279 for l in &net.loads {
280 let (area, zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
281 let id = quoted_device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_quoted);
282 let (pl, ql, ip, iq, yp, yq) = load_components_for_write(l, &id, &mut warnings);
283 let owner = extra_i64(&l.extras, "psse_owner").unwrap_or(1);
284 let scal = typed_psse_scal(l, &id, &mut warnings)
285 .or_else(|| extra_i64(&l.extras, "psse_scal"))
286 .unwrap_or(1);
287 let intrpt = extra_i64(&l.extras, "psse_intrpt").unwrap_or(0);
288 let typed_load_type = l.voltage_model.as_ref().and_then(typed_psse_load_type);
289 if rev < 35 && typed_load_type.is_some() {
290 warnings.push(format!(
291 "PSS/E load at bus {} id {id:?}: load type requires revision 35; dropped",
292 l.bus
293 ));
294 }
295 let modern_tail = if rev >= 35 {
296 let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
297 let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
298 let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
299 let raw_loadtype = typed_load_type.or_else(|| {
300 l.extras
301 .get("psse_loadtype")
302 .and_then(Value::as_str)
303 .map(str::to_owned)
304 });
305 let loadtype =
306 sanitize_quoted(raw_loadtype.as_deref().unwrap_or(""), NAME_FORBIDDEN, ' ');
307 if matches!(loadtype, std::borrow::Cow::Owned(_)) {
308 sanitized_quoted += 1;
309 }
310 format!(
311 ", {}, {}, {flagstatus}, '{loadtype}'",
312 num(pdgen),
313 num(qdgen)
314 )
315 } else if modern {
316 let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
317 let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
318 let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
319 format!(", {}, {}, {flagstatus}", num(pdgen), num(qdgen))
320 } else {
321 String::new()
322 };
323 let _ = writeln!(
324 s,
325 "{}, '{id}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {owner}, {scal}, {intrpt}{modern_tail}",
326 l.bus,
327 i32::from(l.in_service),
328 area,
329 zone,
330 num(pl),
331 num(ql),
332 num(ip),
333 num(iq),
334 num(yp),
335 num(yq)
336 );
337 }
338 let _ = writeln!(s, "0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA");
339
340 let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
342 for sh in net.shunts.iter().filter(|s| s.control.is_none()) {
343 let id = quoted_device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_quoted);
344 let _ = writeln!(
345 s,
346 "{}, '{id}', {}, {}, {}",
347 sh.bus,
348 i32::from(sh.in_service),
349 num(sh.g),
350 num(sh.b)
351 );
352 }
353 let _ = writeln!(s, "0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA");
354
355 let mut gen_ids: BTreeMap<BusId, u32> = BTreeMap::new();
356 for g in &net.generators {
357 let id = positional_id(g.bus, &mut gen_ids);
358 let ireg = g.regulated_bus.map_or(0, |b| b.0);
360 let (nreg, baslod) = if rev >= 35 { (" 0,", " 0,") } else { ("", "") };
362 let _ = writeln!(
363 s,
364 "{}, '{id}', {}, {}, {}, {}, {}, {},{nreg} {}, 0, 1, 0, 0, 1, {}, 100, {}, {},{baslod} 1, 1",
365 g.bus,
366 num(g.pg),
367 num(g.qg),
368 num(g.qmax),
369 num(g.qmin),
370 num(g.vg),
371 ireg,
372 num(g.mbase),
373 i32::from(g.in_service),
374 num(g.pmax),
375 num(g.pmin)
376 );
377 }
378 let _ = writeln!(s, "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA");
379
380 let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
384 for (branch_index, br) in net
385 .branches
386 .iter()
387 .enumerate()
388 .filter(|(_, b)| !b.is_transformer())
389 {
390 let ckt = quoted_circuit_id(
391 br.extras.get("id").and_then(Value::as_str),
392 (br.from, br.to),
393 &mut branch_ids,
394 &mut sanitized_quoted,
395 );
396 let charging = br.terminal_charging();
397 let b_total = charging.total_b();
398 let b_mid = b_total / 2.0;
399 let bi = charging.b_fr - b_mid;
400 let bj = charging.b_to - b_mid;
401 if modern {
402 let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
406 let _ = writeln!(
407 s,
408 "{}, {}, '{ckt}', {}, {}, {}, ' ', {}, {}, {}, \
409 {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
410 br.from,
411 br.to,
412 num(br.r),
413 num(br.x),
414 num(b_total),
415 num(br.rate_a),
416 num(br.rate_b),
417 num(br.rate_c),
418 num(extra_ratings[0]),
419 num(extra_ratings[1]),
420 num(extra_ratings[2]),
421 num(extra_ratings[3]),
422 num(extra_ratings[4]),
423 num(extra_ratings[5]),
424 num(extra_ratings[6]),
425 num(extra_ratings[7]),
426 num(extra_ratings[8]),
427 num(charging.g_fr),
428 num(bi),
429 num(charging.g_to),
430 num(bj),
431 i32::from(br.in_service)
432 );
433 } else {
434 let _ = writeln!(
435 s,
436 "{}, {}, '{ckt}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
437 br.from,
438 br.to,
439 num(br.r),
440 num(br.x),
441 num(b_total),
442 num(br.rate_a),
443 num(br.rate_b),
444 num(br.rate_c),
445 num(charging.g_fr),
446 num(bi),
447 num(charging.g_to),
448 num(bj),
449 i32::from(br.in_service)
450 );
451 }
452 }
453 let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
454
455 for (branch_index, br) in net
456 .branches
457 .iter()
458 .enumerate()
459 .filter(|(_, b)| b.is_transformer())
460 {
461 let charging = br.terminal_charging();
469 let _ = writeln!(
470 s,
471 "{}, {}, 0, '1', 1, 1, 1, {}, {}, 2, ' ', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
472 br.from,
473 br.to,
474 num(charging.total_g()),
475 num(charging.total_b()),
476 i32::from(br.in_service)
477 );
478 let ctl = br.control.as_ref();
481 let sbase = ctl
482 .filter(|c| c.mva_base > 0.0)
483 .map_or(net.base_mva, |c| c.mva_base);
484 let cod = ctl.map_or(0, |c| mode_to_cod(c.mode));
485 let cont = ctl.and_then(|c| c.controlled_bus).map_or(0, |b| b.0);
486 let (rma, rmi, vma, vmi, ntp) = ctl.map_or((1.1, 0.9, 1.1, 0.9, 33), |c| {
487 (c.tap_max, c.tap_min, c.band_max, c.band_min, c.ntp)
488 });
489 let _ = writeln!(s, "{}, {}, {}", num(br.r), num(br.x), num(sbase));
490 if modern {
491 let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
495 let _ = writeln!(
496 s,
497 "{}, 0, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, \
498 {cod}, {cont}, 0, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
499 num(br.effective_tap()),
500 num(br.shift),
501 num(br.rate_a),
502 num(br.rate_b),
503 num(br.rate_c),
504 num(extra_ratings[0]),
505 num(extra_ratings[1]),
506 num(extra_ratings[2]),
507 num(extra_ratings[3]),
508 num(extra_ratings[4]),
509 num(extra_ratings[5]),
510 num(extra_ratings[6]),
511 num(extra_ratings[7]),
512 num(extra_ratings[8]),
513 num(rma),
514 num(rmi),
515 num(vma),
516 num(vmi)
517 );
518 } else {
519 let _ = writeln!(
520 s,
521 "{}, 0, {}, {}, {}, {}, {cod}, {cont}, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
522 num(br.effective_tap()),
523 num(br.shift),
524 num(br.rate_a),
525 num(br.rate_b),
526 num(br.rate_c),
527 num(rma),
528 num(rmi),
529 num(vma),
530 num(vmi)
531 );
532 }
533 let _ = writeln!(s, "1.0, 0");
534 }
535
536 for t in &net.transformers_3w {
540 let raw_name = t.name.as_deref().unwrap_or("");
541 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
542 if matches!(name, std::borrow::Cow::Owned(_)) {
543 sanitized_quoted += 1;
544 }
545 let _ = writeln!(
546 s,
547 "{}, {}, {}, '1', 1, 1, 1, {}, {}, 2, '{:<12}', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
548 t.windings[0].bus,
549 t.windings[1].bus,
550 t.windings[2].bus,
551 num(t.mag_g),
552 num(t.mag_b),
553 name,
554 i32::from(t.in_service)
555 );
556 let [z12, z23, z31] = t.z;
559 let _ = writeln!(
560 s,
561 "{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}",
562 num(z12.r),
563 num(z12.x),
564 num(z12.base_mva),
565 num(z23.r),
566 num(z23.x),
567 num(z23.base_mva),
568 num(z31.r),
569 num(z31.x),
570 num(z31.base_mva),
571 num(t.star_vm),
572 num(t.star_va)
573 );
574 for w in &t.windings {
575 if modern {
576 let _ = writeln!(
579 s,
580 "{}, {}, {}, {}, {}, {}, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, \
581 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
582 num(w.tap),
583 num(w.nominal_kv),
584 num(w.shift),
585 num(w.rate_a),
586 num(w.rate_b),
587 num(w.rate_c)
588 );
589 } else {
590 let _ = writeln!(
591 s,
592 "{}, {}, {}, {}, {}, {}, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
593 num(w.tap),
594 num(w.nominal_kv),
595 num(w.shift),
596 num(w.rate_a),
597 num(w.rate_b),
598 num(w.rate_c)
599 );
600 }
601 }
602 }
603 let _ = writeln!(s, "0 / END OF TRANSFORMER DATA, BEGIN AREA DATA");
604 for a in &net.areas {
605 let raw_name = a.name.as_deref().unwrap_or("");
606 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
607 if matches!(name, std::borrow::Cow::Owned(_)) {
608 sanitized_quoted += 1;
609 }
610 let _ = writeln!(
611 s,
612 "{}, {}, {}, {}, '{:<12}'",
613 a.number,
614 a.slack_bus.map_or(0, |b| b.0),
615 num(a.net_interchange),
616 num(a.tolerance),
617 name
618 );
619 }
620
621 let _ = writeln!(s, "{}", EMPTY_SECTIONS[0]);
625 for (i, dc) in net.hvdc.iter().enumerate() {
626 let raw_name = dc_str(&dc.extras, "psse_dc_name").unwrap_or_else(|| format!("DC{}", i + 1));
627 let name = sanitize_quoted(&raw_name, NAME_FORBIDDEN, ' ');
628 if matches!(name, std::borrow::Cow::Owned(_)) {
629 sanitized_quoted += 1;
630 }
631 let name = format!("'{name}'");
632 let mdc = if dc.in_service {
633 dc_int(&dc.extras, "psse_dc_mdc").unwrap_or(1)
634 } else {
635 0
636 };
637 let rdc = dc_f64(&dc.extras, "psse_dc_rdc").unwrap_or(0.0);
638 let vschd = dc_f64(&dc.extras, "psse_dc_vschd").unwrap_or(0.0);
639 let l1_tail = dc_tail(
640 &dc.extras,
641 "psse_dc_control_tail",
642 "0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0",
643 );
644 let rect_tail = dc_tail(&dc.extras, "psse_dc_rectifier_tail", DEFAULT_CONVERTER_TAIL);
645 let inv_tail = dc_tail(&dc.extras, "psse_dc_inverter_tail", DEFAULT_CONVERTER_TAIL);
646 let _ = writeln!(
647 s,
648 "{name}, {mdc}, {}, {}, {}, {l1_tail}",
649 num(rdc),
650 num(dc.pf),
651 num(vschd)
652 );
653 let _ = writeln!(s, "{}, {rect_tail}", dc.from);
654 let _ = writeln!(s, "{}, {inv_tail}", dc.to);
655 }
656 for line in &EMPTY_SECTIONS[1..=9] {
658 let _ = writeln!(s, "{line}");
659 }
660 let mut sw_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
666 for sh in net.shunts.iter().filter(|s| s.control.is_some()) {
667 let Some(c) = sh.control.as_ref() else {
668 continue;
669 };
670 let swrem = c.control_bus.map_or(0, |b| b.0);
671 let mut blocks = String::new();
672 for blk in &c.blocks {
673 if rev >= 35 {
674 let _ = write!(blocks, ", 1, {}, {}", blk.steps, num(blk.b));
677 } else {
678 let _ = write!(blocks, ", {}, {}", blk.steps, num(blk.b));
679 }
680 }
681 if rev >= 35 {
682 let id = quoted_device_id(&sh.extras, sh.bus, &mut sw_ids, &mut sanitized_quoted);
683 let _ = writeln!(
684 s,
685 "{}, '{id}', {}, 0, {}, {}, {}, {swrem}, 0, {}, '', {}{blocks}",
686 sh.bus,
687 mode_to_modsw(c.mode),
688 i32::from(sh.in_service),
689 num(c.vhigh),
690 num(c.vlow),
691 num(c.rmpct),
692 num(sh.b)
693 );
694 } else {
695 let _ = writeln!(
696 s,
697 "{}, {}, 0, {}, {}, {}, {swrem}, {}, '', {}{blocks}",
698 sh.bus,
699 mode_to_modsw(c.mode),
700 i32::from(sh.in_service),
701 num(c.vhigh),
702 num(c.vlow),
703 num(c.rmpct),
704 num(sh.b)
705 );
706 }
707 }
708 for line in &EMPTY_SECTIONS[10..] {
709 let _ = writeln!(s, "{line}");
710 }
711 let _ = writeln!(s, "Q");
712
713 if net
714 .hvdc
715 .iter()
716 .any(|d| !d.extras.contains_key("psse_dc_name"))
717 {
718 warnings.push(
719 "DC line converter detail (firing angles, converter transformer taps, reactive \
720 output) defaulted: PSS/E two-terminal DC is written from the power setpoint and \
721 line resistance only"
722 .into(),
723 );
724 }
725 if !net.storage.is_empty() {
726 warnings.push(format!(
727 "{} storage unit(s) dropped: PSS/E has no storage record",
728 net.storage.len()
729 ));
730 }
731 if net.generators.iter().any(|g| g.cost.is_some()) {
732 warnings.push("generator cost curves dropped: PSS/E .raw has no cost data".into());
733 }
734 if net.branches.iter().any(Branch::has_angle_limits) {
735 warnings.push(
736 "branch angle limits (angmin/angmax) dropped: PSS/E branch records carry none".into(),
737 );
738 }
739 let current_ratings = net
740 .branches
741 .iter()
742 .filter(|b| b.current_ratings.is_some())
743 .count();
744 if current_ratings > 0 {
745 warnings.push(format!(
746 "{current_ratings} branch current rating record(s) dropped: PSS/E branch ratings are MVA ratings"
747 ));
748 }
749 if !modern {
750 warn_psse_extra_branch_ratings_dropped(net, &mut warnings);
751 }
752 let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
753 if branch_solutions > 0 {
754 warnings.push(format!(
755 "{branch_solutions} branch solution value set(s) dropped: PSS/E RAW power flow result fields are not written"
756 ));
757 }
758 let transformer_terminal_shunts = net
759 .branches
760 .iter()
761 .filter(|b| {
762 b.is_transformer()
763 && b.charging
764 .is_some_and(|c| c.g_to.abs() > f64::EPSILON || c.b_to.abs() > f64::EPSILON)
765 })
766 .count();
767 if transformer_terminal_shunts > 0 {
768 warnings.push(format!(
769 "{transformer_terminal_shunts} transformer terminal admittance record(s) collapsed to magnetizing admittance: PSS/E transformer records cannot preserve terminal side assignment"
770 ));
771 }
772 if net.generators.iter().any(Generator::has_caps) {
773 warnings.push(
774 "generator ramp/capability columns dropped: PSS/E .raw has no equivalent fields".into(),
775 );
776 }
777 if nonfinite {
778 warnings.push("non-finite values written as ±1e10 sentinels (PSS/E has no Inf/NaN)".into());
779 }
780 if sanitized_quoted > 0 {
781 warnings.push(format!(
782 "{sanitized_quoted} quoted PSS/E field(s) contained a quote or '/' that would \
783 corrupt a record; replaced with spaces"
784 ));
785 }
786
787 Conversion { text: s, warnings }
788}
789
790fn ide(kind: BusType) -> u8 {
792 kind as u8 }
794
795fn quoted_device_id(
801 extras: &Extras,
802 bus: BusId,
803 used: &mut BTreeMap<BusId, BTreeSet<String>>,
804 sanitized_quoted: &mut usize,
805) -> String {
806 quoted_circuit_id(
807 extras.get("id").and_then(Value::as_str),
808 bus,
809 used,
810 sanitized_quoted,
811 )
812}
813
814fn quoted_circuit_id<K: Ord + Clone>(
815 preferred: Option<&str>,
816 key: K,
817 used: &mut BTreeMap<K, BTreeSet<String>>,
818 sanitized_quoted: &mut usize,
819) -> String {
820 let sanitized = preferred.map(|id| {
821 let cleaned = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
822 if matches!(cleaned, std::borrow::Cow::Owned(_)) {
823 *sanitized_quoted += 1;
824 }
825 cleaned.into_owned()
826 });
827 super::allocate_circuit_id(sanitized.as_deref(), key, used)
828}
829
830fn positional_id(bus: BusId, counters: &mut BTreeMap<BusId, u32>) -> String {
833 let n = counters.entry(bus).or_insert(0);
834 *n += 1;
835 n.to_string()
836}
837
838const DEFAULT_CONVERTER_TAIL: &str =
843 "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";
844
845const EMPTY_SECTIONS: [&str; 13] = [
846 "0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA",
847 "0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA",
848 "0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA",
849 "0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA",
850 "0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA",
851 "0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA",
852 "0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA",
853 "0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA",
854 "0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA",
855 "0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA",
856 "0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA",
857 "0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA",
858 "0 / END OF INDUCTION MACHINE DATA",
859];
860
861pub fn parse_psse(content: &str) -> Result<Network> {
867 let mut warnings = Vec::new();
868 parse_psse_source(Arc::new(content.to_owned()), None, &mut warnings)
869}
870
871pub(crate) fn header_rev(source: &str) -> u32 {
876 let Some(header) = source
877 .lines()
878 .map(str::trim)
879 .find(|line| !line.is_empty() && !is_comment(line))
880 else {
881 return 33;
882 };
883 fields(header)
884 .get(2)
885 .and_then(|f| f.parse::<f64>().ok())
886 .filter(|v| v.is_finite() && *v >= 0.0)
887 .map_or(33, |v| v as u32)
888}
889
890#[expect(clippy::too_many_lines)]
896pub(crate) fn parse_psse_source(
897 source: Arc<String>,
898 name_hint: Option<&str>,
899 warnings: &mut Vec<String>,
900) -> Result<Network> {
901 let content: &str = &source;
902 let mut lines = content.lines();
903
904 let header = lines
906 .by_ref()
907 .find(|line| {
908 let line = line.trim();
909 !line.is_empty() && !is_comment(line)
910 })
911 .ok_or_else(|| Error::FormatRead {
912 format: FMT,
913 message: "empty file".into(),
914 })?;
915 let header_fields = fields(header);
916 let base_mva = header_fields
917 .get(1)
918 .and_then(|f| f.parse::<f64>().ok())
919 .ok_or_else(|| Error::FormatRead {
920 format: FMT,
921 message: "missing SBASE in header".into(),
922 })?;
923 let raw_rev = header_fields
924 .get(2)
925 .and_then(|f| f.parse::<f64>().ok())
926 .filter(|v| v.is_finite() && *v >= 0.0)
927 .map_or(33, |v| v as u32);
928 let base_frequency = header_fields
931 .get(5)
932 .and_then(|f| f.parse::<f64>().ok())
933 .filter(|v| v.is_finite() && *v > 0.0)
934 .unwrap_or(crate::network::DEFAULT_BASE_FREQUENCY);
935 let title = lines.next().unwrap_or("").trim();
937 let name = if title.is_empty() {
938 name_hint.unwrap_or("case").to_string()
939 } else {
940 title.to_string()
941 };
942 lines.next(); let mut buses = Vec::new();
945 let mut loads = Vec::new();
946 let mut shunts = Vec::new();
947 let mut generators = Vec::new();
948 let mut branches = Vec::new();
949 let mut transformers_3w = Vec::new();
950 let mut hvdc = Vec::new();
951 let mut areas = Vec::new();
952 let mut solver = SolverParams::default();
953 let mut bus_base_kv: BTreeMap<BusId, f64> = BTreeMap::new();
954 let mut unmodeled_sections: BTreeMap<String, usize> = BTreeMap::new();
955
956 let mut section = Section::Bus;
959 let mut saw_bus_marker = false;
960 let mut skipped_section_name: Option<String> = None;
961 let mut lines = lines.peekable();
962 while let Some(raw) = lines.next() {
963 let line = raw.trim();
964 if line.is_empty() {
965 continue;
966 }
967 if is_comment(line) {
968 continue;
969 }
970 if line == "Q" {
971 break;
972 }
973 if is_terminator(line) {
974 let next_section = section_after_marker(line);
979 skipped_section_name =
980 introduced_section_name(line).filter(|_| matches!(next_section, Section::Skip));
981 section = next_section;
982 saw_bus_marker |= matches!(section, Section::Bus);
983 continue;
984 }
985 let f = fields(line);
986 match section {
987 Section::Bus if !saw_bus_marker && buses.is_empty() && is_system_wide_record(&f) => {
988 section = Section::SystemWide;
991 parse_solver_line(&f, &mut solver);
992 }
993 Section::Bus => {
994 let bus = read_bus(&f)?;
995 bus_base_kv.insert(bus.id, bus.base_kv);
996 buses.push(bus);
997 }
998 Section::Load => loads.push(read_load(&f, raw_rev, warnings)?),
999 Section::FixedShunt => shunts.push(read_shunt(&f)?),
1000 Section::SwitchedShunt => shunts.push(read_switched_shunt(&f, raw_rev)?),
1001 Section::Generator => generators.push(read_gen(&f, raw_rev)?),
1002 Section::Branch => branches.push(read_branch(&f, raw_rev)?),
1003 Section::Transformer => {
1004 let two_winding = int_at(&f, 2, 0)? == 0;
1009 let l2 = next_continuation_line(
1010 &mut lines,
1011 "transformer",
1012 "transformer impedance line",
1013 )?;
1014 let l3 = next_continuation_line(&mut lines, "transformer", "winding data line 1")?;
1015 let l4 = next_continuation_line(&mut lines, "transformer", "winding data line 2")?;
1016 if two_winding {
1017 if int_at(&f, 6, 1)? != 1 && num_at(&f, 8, 0.0)? != 0.0 {
1021 warnings.push(format!(
1022 "transformer {}-{}: magnetizing data with CM != 1 dropped \
1023 (only CM = 1 p.u. susceptance is read as branch charging)",
1024 f.first().map_or("?", String::as_str),
1025 f.get(1).map_or("?", String::as_str),
1026 ));
1027 }
1028 branches.push(read_transformer(
1029 &f,
1030 &fields(l2),
1031 &fields(l3),
1032 &fields(l4),
1033 raw_rev,
1034 base_mva,
1035 &bus_base_kv,
1036 warnings,
1037 )?);
1038 } else {
1039 let l5 =
1040 next_continuation_line(&mut lines, "transformer", "winding data line 3")?;
1041 transformers_3w.push(read_transformer_3w(
1042 &f,
1043 &fields(l2),
1044 &fields(l3),
1045 &fields(l4),
1046 &fields(l5),
1047 base_mva,
1048 &bus_base_kv,
1049 warnings,
1050 )?);
1051 }
1052 }
1053 Section::TwoTerminalDc => {
1054 let rectifier =
1057 next_continuation_line(&mut lines, "two-terminal DC", "rectifier line")?;
1058 let inverter =
1059 next_continuation_line(&mut lines, "two-terminal DC", "inverter line")?;
1060 hvdc.push(read_dc_line(&f, &fields(rectifier), &fields(inverter))?);
1061 }
1062 Section::Area => areas.push(read_area(&f)?),
1063 Section::SystemWide => parse_solver_line(&f, &mut solver),
1064 Section::Skip => {
1065 if let Some(name) = skipped_section_name.as_ref() {
1066 *unmodeled_sections.entry(name.clone()).or_default() += 1;
1067 }
1068 }
1069 }
1070 }
1071
1072 warn_unmodeled_sections(unmodeled_sections, warnings);
1073
1074 let mut net = Network {
1075 name,
1076 base_mva,
1077 base_frequency,
1078 buses,
1079 loads,
1080 shunts,
1081 branches,
1082 switches: Vec::new(),
1083 generators,
1084 storage: Vec::new(),
1085 hvdc,
1086 transformers_3w,
1087 areas,
1088 solver: (!solver.is_empty()).then_some(solver),
1089 source_format: SourceFormat::Psse,
1090 source: Some(source),
1091 };
1092 drop_stale_control_pointers(&mut net, warnings);
1093 net.check_references(FMT)?;
1094 Ok(net)
1095}
1096
1097#[derive(Clone, Copy)]
1098enum Section {
1099 Bus,
1100 Load,
1101 FixedShunt,
1102 SwitchedShunt,
1103 Generator,
1104 Branch,
1105 Transformer,
1106 TwoTerminalDc,
1107 Area,
1108 SystemWide,
1109 Skip,
1110}
1111
1112fn section_after_marker(line: &str) -> Section {
1116 match introduced_section_name(line).as_deref() {
1117 Some("BUS") => Section::Bus,
1118 Some("LOAD") => Section::Load,
1119 Some("FIXED SHUNT") => Section::FixedShunt,
1120 Some("SWITCHED SHUNT") => Section::SwitchedShunt,
1121 Some("GENERATOR" | "GEN") => Section::Generator,
1122 Some("BRANCH") => Section::Branch,
1123 Some("TRANSFORMER") => Section::Transformer,
1124 Some("TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC") => {
1125 Section::TwoTerminalDc
1126 }
1127 Some("AREA" | "AREA INTERCHANGE") => Section::Area,
1128 _ => Section::Skip,
1129 }
1130}
1131
1132fn is_terminator(line: &str) -> bool {
1134 fields(line).first().map(String::as_str) == Some("0")
1135}
1136
1137fn next_continuation_line<'a>(
1138 lines: &mut std::iter::Peekable<std::str::Lines<'a>>,
1139 record: &str,
1140 expected: &str,
1141) -> Result<&'a str> {
1142 for line in lines.by_ref().map(str::trim) {
1143 if line.is_empty() || is_comment(line) {
1144 continue;
1145 }
1146 if line.eq_ignore_ascii_case("q") || is_section_marker(line) || is_bare_terminator(line) {
1147 return Err(Error::FormatRead {
1148 format: FMT,
1149 message: format!(
1150 "PSS/E {record} record ended before {expected}: found section terminator `{line}`"
1151 ),
1152 });
1153 }
1154 return Ok(line);
1155 }
1156 Err(Error::FormatRead {
1157 format: FMT,
1158 message: format!("PSS/E {record} record ended before {expected}"),
1159 })
1160}
1161
1162fn is_bare_terminator(line: &str) -> bool {
1163 let f = fields(line);
1164 f.len() == 1 && f.first().map(String::as_str) == Some("0")
1165}
1166
1167fn transformer_basis_codes(f: &[String]) -> Result<(i64, i64)> {
1168 let cw = num_at(f, 4, 1.0)?;
1169 if cw.fract() != 0.0 {
1170 return Err(bad_field(4, f.get(4).map_or("", String::as_str)));
1171 }
1172 let cz = num_at(f, 5, 1.0)?;
1173 if cz.fract() != 0.0 {
1174 return Err(bad_field(5, f.get(5).map_or("", String::as_str)));
1175 }
1176 #[allow(clippy::cast_possible_truncation)]
1177 Ok((cw as i64, cz as i64))
1178}
1179
1180fn transformer_label(f: &[String]) -> String {
1181 let i = f.first().map_or("?", String::as_str);
1182 let j = f.get(1).map_or("?", String::as_str);
1183 let k = f.get(2).map_or("?", String::as_str);
1184 let id = f.get(3).map_or("", String::as_str);
1185 format!("{i}-{j}-{k} id {id:?}")
1186}
1187
1188#[expect(clippy::too_many_arguments)]
1189fn convert_transformer_impedance(
1190 r: f64,
1191 x: f64,
1192 sbase: f64,
1193 system_base: f64,
1194 cz: i64,
1195 label: &str,
1196 pair: &str,
1197 warnings: &mut Vec<String>,
1198) -> (f64, f64) {
1199 let base_ok = sbase.is_finite() && sbase > 0.0;
1200 match cz {
1201 1 => (r, x),
1202 2 => {
1203 if base_ok {
1204 let scale = system_base / sbase;
1205 (r * scale, x * scale)
1206 } else {
1207 warnings.push(format!(
1208 "PSS/E transformer {label} pair {pair}: CZ=2 impedance has invalid SBASE {sbase}; read as system-base p.u."
1209 ));
1210 (r, x)
1211 }
1212 }
1213 3 => {
1214 if !base_ok {
1215 warnings.push(format!(
1216 "PSS/E transformer {label} pair {pair}: CZ=3 impedance has invalid SBASE {sbase}; read as system-base p.u."
1217 ));
1218 return (r, x);
1219 }
1220 let r_pair = (r / 1_000_000.0) / sbase;
1221 let z_pair = x.abs();
1222 let x_pair = (z_pair.mul_add(z_pair, -(r_pair * r_pair)))
1223 .max(0.0)
1224 .sqrt()
1225 .copysign(x);
1226 let scale = system_base / sbase;
1227 (r_pair * scale, x_pair * scale)
1228 }
1229 other => {
1230 warnings.push(format!(
1231 "PSS/E transformer {label} pair {pair}: unsupported CZ={other}; read impedance as system-base p.u."
1232 ));
1233 (r, x)
1234 }
1235 }
1236}
1237
1238fn default_windv(cw: i64, bus: BusId, bus_base_kv: &BTreeMap<BusId, f64>) -> f64 {
1239 if cw == 2 {
1240 bus_base_kv
1241 .get(&bus)
1242 .copied()
1243 .filter(|v| *v > 0.0)
1244 .unwrap_or(1.0)
1245 } else {
1246 1.0
1247 }
1248}
1249
1250fn winding_ratio(
1251 w: &[String],
1252 bus: BusId,
1253 cw: i64,
1254 bus_base_kv: &BTreeMap<BusId, f64>,
1255 label: &str,
1256 winding: &str,
1257 warnings: &mut Vec<String>,
1258) -> Result<f64> {
1259 let windv = num_at(w, 0, default_windv(cw, bus, bus_base_kv))?;
1260 let nomv = num_at(w, 1, 0.0)?;
1261 let base_kv = bus_base_kv.get(&bus).copied().unwrap_or(0.0);
1262 let needs_base = matches!(cw, 2 | 3);
1263 if needs_base && !(base_kv.is_finite() && base_kv > 0.0) {
1264 warnings.push(format!(
1265 "PSS/E transformer {label} {winding}: CW={cw} needs a positive bus base kV for bus {bus}; read WINDV as a p.u. tap ratio"
1266 ));
1267 return Ok(windv);
1268 }
1269 match cw {
1270 1 => Ok(windv),
1271 2 => Ok(windv / base_kv),
1272 3 => {
1273 let nominal = if nomv.is_finite() && nomv > 0.0 {
1274 nomv
1275 } else {
1276 base_kv
1277 };
1278 Ok(windv * nominal / base_kv)
1279 }
1280 other => {
1281 warnings.push(format!(
1282 "PSS/E transformer {label} {winding}: unsupported CW={other}; read WINDV as a p.u. tap ratio"
1283 ));
1284 Ok(windv)
1285 }
1286 }
1287}
1288
1289#[expect(clippy::too_many_arguments)]
1290fn two_winding_tap(
1291 l1: &[String],
1292 l3: &[String],
1293 l4: &[String],
1294 from: BusId,
1295 to: BusId,
1296 cw: i64,
1297 bus_base_kv: &BTreeMap<BusId, f64>,
1298 warnings: &mut Vec<String>,
1299) -> Result<f64> {
1300 let label = transformer_label(l1);
1301 let ratio1 = winding_ratio(l3, from, cw, bus_base_kv, &label, "winding 1", warnings)?;
1302 let ratio2 = winding_ratio(l4, to, cw, bus_base_kv, &label, "winding 2", warnings)?;
1303 if ratio2.abs() <= f64::EPSILON {
1304 warnings.push(format!(
1305 "PSS/E transformer {label}: winding 2 ratio is zero; used winding 1 ratio as the branch tap"
1306 ));
1307 Ok(ratio1)
1308 } else {
1309 Ok(ratio1 / ratio2)
1310 }
1311}
1312
1313fn is_section_marker(line: &str) -> bool {
1316 if !is_terminator(line) {
1317 return false;
1318 }
1319 let u = line.to_ascii_uppercase();
1320 u.contains("END OF") || u.contains("BEGIN ") || u.contains("START OF ")
1321}
1322
1323fn introduced_section_name(line: &str) -> Option<String> {
1326 let u = line.to_ascii_uppercase();
1327 let (start, prefix_len) = u
1328 .find("BEGIN ")
1329 .map(|idx| (idx, "BEGIN ".len()))
1330 .or_else(|| u.find("START OF ").map(|idx| (idx, "START OF ".len())))?;
1331 let start = start + prefix_len;
1332 let rest = &u[start..];
1333 let end = rest.find(" DATA")?;
1334 Some(rest[..end].trim().to_string())
1335}
1336
1337fn warn_unmodeled_sections(totals: BTreeMap<String, usize>, warnings: &mut Vec<String>) {
1343 for (name, rows) in totals {
1344 warnings.push(format!(
1345 "PSS/E {name} section ({rows} record line(s)) is not modeled: preserved only in a \
1346 same-format .raw echo, dropped on any other write"
1347 ));
1348 }
1349}
1350
1351fn drop_stale_control_pointers(net: &mut Network, warnings: &mut Vec<String>) {
1352 let bus_ids: BTreeSet<BusId> = net.buses.iter().map(|b| b.id).collect();
1353 let missing = |bus: BusId| !bus_ids.contains(&bus);
1354
1355 for (idx, g) in net.generators.iter_mut().enumerate() {
1356 let Some(bus) = g.regulated_bus.filter(|b| missing(*b)) else {
1357 continue;
1358 };
1359 warnings.push(format!(
1360 "PSS/E GENERATOR DATA record {} at bus {}: IREG references missing bus id {}; dropped remote voltage control",
1361 idx + 1,
1362 g.bus,
1363 bus
1364 ));
1365 g.regulated_bus = None;
1366 }
1367
1368 for (idx, br) in net.branches.iter_mut().enumerate() {
1369 let Some(control) = br.control.as_mut() else {
1370 continue;
1371 };
1372 let Some(bus) = control.controlled_bus.filter(|b| missing(*b)) else {
1373 continue;
1374 };
1375 warnings.push(format!(
1376 "PSS/E TRANSFORMER DATA record {} ({}-{}): CONT references missing bus id {}; dropped transformer control pointer",
1377 idx + 1,
1378 br.from,
1379 br.to,
1380 bus
1381 ));
1382 control.controlled_bus = None;
1383 }
1384
1385 for (idx, shunt) in net.shunts.iter_mut().enumerate() {
1386 let Some(control) = shunt.control.as_mut() else {
1387 continue;
1388 };
1389 let Some(bus) = control.control_bus.filter(|b| missing(*b)) else {
1390 continue;
1391 };
1392 warnings.push(format!(
1393 "PSS/E SWITCHED SHUNT DATA record {} at bus {}: SWREM references missing bus id {}; dropped switched shunt control pointer",
1394 idx + 1,
1395 shunt.bus,
1396 bus
1397 ));
1398 control.control_bus = None;
1399 }
1400
1401 for (idx, area) in net.areas.iter_mut().enumerate() {
1402 let Some(bus) = area.slack_bus.filter(|b| missing(*b)) else {
1403 continue;
1404 };
1405 warnings.push(format!(
1406 "PSS/E AREA DATA record {} area {}: ISW references missing bus id {}; dropped area swing pointer",
1407 idx + 1,
1408 area.number,
1409 bus
1410 ));
1411 area.slack_bus = None;
1412 }
1413}
1414
1415fn is_comment(line: &str) -> bool {
1416 line.starts_with("@!") || line.starts_with('@')
1417}
1418
1419fn is_system_wide_record(f: &[String]) -> bool {
1420 matches!(
1421 f.first().map(|s| s.to_ascii_uppercase()),
1422 Some(first) if matches!(first.as_str(), "GENERAL" | "RATING" | "NEWTON" | "SOLVER")
1423 )
1424}
1425
1426fn parse_solver_line(f: &[String], solver: &mut SolverParams) {
1430 let Some(keyword) = f.first().map(|s| s.to_ascii_uppercase()) else {
1431 return;
1432 };
1433 for tok in &f[1..] {
1434 let Some((key, val)) = tok.split_once('=') else {
1435 continue;
1436 };
1437 let (key, val) = (key.trim().to_ascii_uppercase(), val.trim());
1438 match (keyword.as_str(), key.as_str()) {
1439 ("GENERAL", "THRSHZ") => solver.zero_impedance_threshold = val.parse().ok(),
1440 ("NEWTON", "TOLN") => solver.newton_tolerance = val.parse().ok(),
1441 ("NEWTON", "ITMXN") => solver.max_iterations = val.parse().ok(),
1442 ("SOLVER", "ACTAPS") => solver.adjust_taps = Some(parse_enable(val)),
1443 ("SOLVER", "AREAIN") => solver.adjust_area_interchange = Some(parse_enable(val)),
1444 ("SOLVER", "PHSHFT") => solver.adjust_phase_shift = Some(parse_enable(val)),
1445 ("SOLVER", "DCTAPS") => solver.adjust_dc_taps = Some(parse_enable(val)),
1446 ("SOLVER", "SWSHNT") => solver.adjust_switched_shunt = Some(parse_enable(val)),
1447 _ => {}
1448 }
1449 }
1450}
1451
1452fn parse_enable(val: &str) -> bool {
1455 val.parse::<f64>().map_or_else(
1456 |_| !matches!(val.to_ascii_uppercase().as_str(), "DISABLED" | "OFF" | "NO"),
1457 |n| n != 0.0,
1458 )
1459}
1460
1461fn strip_inline_comment(line: &str) -> &str {
1464 let mut quoted = false;
1465 for (i, c) in line.char_indices() {
1466 match c {
1467 '\'' => quoted = !quoted,
1468 '/' if !quoted => return &line[..i],
1469 _ => {}
1470 }
1471 }
1472 line
1473}
1474
1475fn fields(line: &str) -> Vec<String> {
1480 let code = strip_inline_comment(line);
1481 let mut out = Vec::new();
1482 let mut cur = String::new();
1483 let mut quoted = false;
1484 let comma_delimited = code.contains(',');
1485 for c in code.chars() {
1486 match c {
1487 '\'' => quoted = !quoted,
1488 ',' if !quoted && comma_delimited => {
1489 out.push(std::mem::take(&mut cur).trim().to_string());
1490 }
1491 c if c.is_whitespace() && !quoted && !comma_delimited => {
1492 if !cur.is_empty() {
1493 out.push(std::mem::take(&mut cur));
1494 }
1495 }
1496 c => cur.push(c),
1497 }
1498 }
1499 let last = cur.trim().to_string();
1500 if comma_delimited || !last.is_empty() {
1501 out.push(last);
1502 }
1503 out
1504}
1505
1506fn bad_field(i: usize, tok: &str) -> Error {
1507 Error::FormatRead {
1508 format: FMT,
1509 message: format!("field {i} {tok:?} is not a number"),
1510 }
1511}
1512
1513fn num_at(f: &[String], i: usize, default: f64) -> Result<f64> {
1518 match f.get(i).map(String::as_str) {
1519 None | Some("") => Ok(default),
1520 Some(s) => s.parse().map_err(|_| bad_field(i, s)),
1521 }
1522}
1523fn id_at(f: &[String], i: usize, default: usize) -> Result<usize> {
1525 match f.get(i).map(String::as_str) {
1526 None | Some("") => Ok(default),
1527 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1528 Some(s) => s
1529 .parse::<f64>()
1530 .map(|v| v as usize)
1531 .map_err(|_| bad_field(i, s)),
1532 }
1533}
1534fn on_at(f: &[String], i: usize, default: bool) -> Result<bool> {
1536 match f.get(i).map(String::as_str) {
1537 None | Some("") => Ok(default),
1538 Some(s) => s
1539 .parse::<f64>()
1540 .map(|v| v != 0.0)
1541 .map_err(|_| bad_field(i, s)),
1542 }
1543}
1544fn int_at(f: &[String], i: usize, default: i64) -> Result<i64> {
1546 match f.get(i).map(String::as_str) {
1547 None | Some("") => Ok(default),
1548 #[allow(clippy::cast_possible_truncation)]
1551 Some(s) => s
1552 .parse::<f64>()
1553 .map(|v| v as i64)
1554 .map_err(|_| bad_field(i, s)),
1555 }
1556}
1557
1558fn bustype(code: i64) -> BusType {
1559 match code {
1560 2 => BusType::Pv,
1561 3 => BusType::Ref,
1562 4 => BusType::Isolated,
1563 _ => BusType::Pq,
1564 }
1565}
1566
1567#[allow(clippy::float_cmp)]
1570fn read_bus(f: &[String]) -> Result<Bus> {
1571 let id = f
1573 .first()
1574 .and_then(|x| x.parse::<f64>().ok())
1575 .ok_or_else(|| Error::FormatRead {
1576 format: FMT,
1577 message: "bus record missing numeric id (field I)".into(),
1578 })? as usize;
1579 let name = f
1580 .get(1)
1581 .filter(|n| !n.is_empty())
1582 .map(|n| n.trim().to_string());
1583 let vmax = num_at(f, 9, 1.1)?;
1584 let vmin = num_at(f, 10, 0.9)?;
1585 let evhi = num_at(f, 11, vmax)?;
1588 let evlo = num_at(f, 12, vmin)?;
1589 Ok(Bus {
1590 id: BusId(id),
1591 kind: bustype(int_at(f, 3, 1)?),
1592 vm: num_at(f, 7, 1.0)?,
1593 va: num_at(f, 8, 0.0)?,
1594 base_kv: num_at(f, 2, 0.0)?,
1595 vmax,
1596 vmin,
1597 evhi: (evhi != vmax).then_some(evhi),
1598 evlo: (evlo != vmin).then_some(evlo),
1599 area: id_at(f, 4, 0)?,
1600 zone: id_at(f, 5, 0)?,
1601 name,
1602 uid: None,
1603 extras: Extras::new(),
1604 })
1605}
1606
1607fn device_extras(f: &[String], i: usize) -> Extras {
1611 let mut extras = Extras::new();
1612 if let Some(id) = f.get(i).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1613 extras.insert("id".into(), Value::String(id.to_string()));
1614 }
1615 extras
1616}
1617
1618fn read_load(f: &[String], raw_rev: u32, warnings: &mut Vec<String>) -> Result<Load> {
1619 let bus = id_at(f, 0, 0)?;
1621 let id = f.get(1).map_or("", |s| s.trim());
1622 let pl = num_at(f, 5, 0.0)?;
1623 let ql = num_at(f, 6, 0.0)?;
1624 let ip = num_at(f, 7, 0.0)?;
1625 let iq = num_at(f, 8, 0.0)?;
1626 let yp = num_at(f, 9, 0.0)?;
1627 let yq = num_at(f, 10, 0.0)?;
1628 let mut extras = device_extras(f, 1);
1629 for (key, value) in [
1630 ("psse_pl", pl),
1631 ("psse_ql", ql),
1632 ("psse_ip", ip),
1633 ("psse_iq", iq),
1634 ("psse_yp", yp),
1635 ("psse_yq", yq),
1636 ] {
1637 extras.insert(key.into(), jnum(value));
1638 }
1639 for (field, key, default) in [
1640 (11, "psse_owner", 1_i64),
1641 (12, "psse_scal", 1_i64),
1642 (13, "psse_intrpt", 0_i64),
1643 ] {
1644 let value = int_at(f, field, default)?;
1645 if value != default {
1646 extras.insert(key.into(), Value::from(value));
1647 }
1648 }
1649 if raw_rev >= 34 {
1650 for (field, key) in [(14, "psse_pdgen"), (15, "psse_qdgen")] {
1651 let value = num_at(f, field, 0.0)?;
1652 if value != 0.0 {
1653 extras.insert(key.into(), jnum(value));
1654 }
1655 }
1656 let flag = int_at(f, 16, 0)?;
1657 if flag != 0 {
1658 extras.insert("psse_flagstatus".into(), Value::from(flag));
1659 }
1660 }
1661 if raw_rev >= 35 {
1662 if let Some(loadtype) = f.get(17).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1663 extras.insert("psse_loadtype".into(), Value::String(loadtype.to_string()));
1664 }
1665 }
1666 let scal = int_at(f, 12, 1)?;
1667 let load_type = f.get(17).and_then(|s| s.trim().parse::<i32>().ok());
1668 let has_zip_components = [ip, iq, yp, yq].iter().any(|v| *v != 0.0);
1669 let voltage_model =
1670 (has_zip_components || scal != 1 || load_type.is_some()).then_some(LoadVoltageModel::Zip {
1671 p_constant_power: pl,
1672 q_constant_power: ql,
1673 p_constant_current: ip,
1674 q_constant_current: iq,
1675 p_constant_impedance: yp,
1676 q_constant_impedance: yq,
1677 v_nom: None,
1678 load_type,
1679 scaling: (scal != 1).then_some(scal as f64),
1680 });
1681 let has_load_options = extras.contains_key("psse_intrpt")
1682 || extras.contains_key("psse_pdgen")
1683 || extras.contains_key("psse_qdgen")
1684 || extras.contains_key("psse_flagstatus");
1685 if has_load_options {
1686 warnings.push(format!(
1687 "PSS/E load at bus {bus} id {id:?}: interruptible/DG/flag fields are retained in extras"
1688 ));
1689 }
1690 Ok(Load {
1691 bus: BusId(bus),
1692 p: pl + ip + yp,
1693 q: ql + iq + yq,
1694 voltage_model,
1695 in_service: on_at(f, 2, true)?,
1696 uid: None,
1697 extras,
1698 })
1699}
1700
1701fn read_shunt(f: &[String]) -> Result<Shunt> {
1702 Ok(Shunt {
1704 bus: BusId(id_at(f, 0, 0)?),
1705 g: num_at(f, 3, 0.0)?,
1706 b: num_at(f, 4, 0.0)?,
1707 in_service: on_at(f, 2, true)?,
1708 control: None,
1709 uid: None,
1710 extras: device_extras(f, 1),
1711 })
1712}
1713
1714fn read_switched_shunt(f: &[String], rev: u32) -> Result<Shunt> {
1715 let o = usize::from(rev >= 35);
1722 let o2 = 2 * o;
1723 let bus = id_at(f, 0, 0)?;
1724 let swrem = id_at(f, 6 + o, 0)?;
1725 let mut blocks = Vec::new();
1729 let mut i = 10 + o2;
1730 let stride = 2 + o;
1731 while i + stride <= f.len() {
1732 let steps = int_at(f, i + o, 0)?;
1733 let b = num_at(f, i + o + 1, 0.0)?;
1734 if steps == 0 && b == 0.0 {
1735 break;
1736 }
1737 blocks.push(ShuntBlock {
1738 steps: steps.clamp(0, i64::from(u32::MAX)) as u32,
1739 b,
1740 });
1741 i += stride;
1742 }
1743 let control = SwitchedShuntControl {
1744 mode: modsw_to_mode(int_at(f, 1 + o, 1)?),
1745 vhigh: num_at(f, 4 + o, 0.0)?,
1746 vlow: num_at(f, 5 + o, 0.0)?,
1747 control_bus: (swrem != 0 && swrem != bus).then_some(BusId(swrem)),
1748 rmpct: num_at(f, 7 + o2, 100.0)?,
1749 blocks,
1750 };
1751 Ok(Shunt {
1752 bus: BusId(bus),
1753 g: 0.0,
1754 b: num_at(f, 9 + o2, 0.0)?,
1755 in_service: on_at(f, 3 + o, true)?,
1756 control: Some(control),
1757 uid: None,
1758 extras: if rev >= 35 {
1760 device_extras(f, 1)
1761 } else {
1762 Extras::new()
1763 },
1764 })
1765}
1766
1767fn modsw_to_mode(modsw: i64) -> SwitchedShuntMode {
1769 match modsw {
1770 0 => SwitchedShuntMode::Locked,
1771 1 => SwitchedShuntMode::Continuous,
1772 _ => SwitchedShuntMode::Discrete,
1773 }
1774}
1775
1776fn mode_to_modsw(mode: SwitchedShuntMode) -> i64 {
1779 match mode {
1780 SwitchedShuntMode::Locked => 0,
1781 SwitchedShuntMode::Continuous => 1,
1782 SwitchedShuntMode::Discrete => 2,
1783 }
1784}
1785
1786fn read_area(f: &[String]) -> Result<Area> {
1787 let isw = id_at(f, 1, 0)?;
1789 Ok(Area {
1790 number: id_at(f, 0, 0)?,
1791 slack_bus: (isw != 0).then_some(BusId(isw)),
1792 net_interchange: num_at(f, 2, 0.0)?,
1793 tolerance: num_at(f, 3, 0.0)?,
1794 name: f
1795 .get(4)
1796 .filter(|n| !n.trim().is_empty())
1797 .map(|n| n.trim().to_string()),
1798 })
1799}
1800
1801fn read_gen(f: &[String], raw_rev: u32) -> Result<Generator> {
1802 let o = usize::from(raw_rev >= 35);
1806 let bus = id_at(f, 0, 0)?;
1807 let ireg = id_at(f, 7, 0)?;
1810 Ok(Generator {
1811 bus: BusId(bus),
1812 pg: num_at(f, 2, 0.0)?,
1813 qg: num_at(f, 3, 0.0)?,
1814 qmax: num_at(f, 4, 0.0)?,
1815 qmin: num_at(f, 5, 0.0)?,
1816 vg: num_at(f, 6, 1.0)?,
1817 mbase: num_at(f, 8 + o, 100.0)?,
1818 in_service: on_at(f, 14 + o, true)?,
1819 pmax: num_at(f, 16 + o, 0.0)?,
1820 pmin: num_at(f, 17 + o, 0.0)?,
1821 cost: None,
1822 caps: Default::default(),
1823 regulated_bus: (ireg != 0 && ireg != bus).then_some(BusId(ireg)),
1824 uid: None,
1825 })
1826}
1827
1828fn read_branch(f: &[String], raw_rev: u32) -> Result<Branch> {
1829 let named_record = raw_rev >= 34 && f.len() >= 24;
1834 let rating = if named_record { 7 } else { 6 };
1835 let status = if named_record { 23 } else { 13 };
1836 let shunt = if named_record { 19 } else { 9 };
1837 let br_b = num_at(f, 5, 0.0)?;
1838 let g_fr = num_at(f, shunt, 0.0)?;
1839 let b_fr_extra = num_at(f, shunt + 1, 0.0)?;
1840 let g_to = num_at(f, shunt + 2, 0.0)?;
1841 let b_to_extra = num_at(f, shunt + 3, 0.0)?;
1842 let b_fr = br_b / 2.0 + b_fr_extra;
1843 let b_to = br_b / 2.0 + b_to_extra;
1844 Ok(Branch {
1845 from: BusId(id_at(f, 0, 0)?),
1846 to: BusId(id_at(f, 1, 0)?),
1847 r: num_at(f, 3, 0.0)?,
1848 x: num_at(f, 4, 0.0)?,
1849 b: b_fr + b_to,
1850 charging: Some(BranchCharging {
1851 g_fr,
1852 b_fr,
1853 g_to,
1854 b_to,
1855 }),
1856 rate_a: num_at(f, rating, 0.0)?,
1857 rate_b: num_at(f, rating + 1, 0.0)?,
1858 rate_c: num_at(f, rating + 2, 0.0)?,
1859 rating_sets: read_extra_branch_ratings(f, rating, named_record)?,
1860 current_ratings: None,
1861 tap: 0.0,
1862 shift: 0.0,
1863 in_service: on_at(f, status, true)?,
1864 angmin: -360.0,
1865 angmax: 360.0,
1866 control: None,
1867 solution: None,
1868 uid: None,
1869 extras: device_extras(f, 2),
1871 })
1872}
1873
1874#[expect(clippy::too_many_arguments)]
1875fn read_transformer(
1876 l1: &[String],
1877 l2: &[String],
1878 l3: &[String],
1879 l4: &[String],
1880 raw_rev: u32,
1881 system_base: f64,
1882 bus_base_kv: &BTreeMap<BusId, f64>,
1883 warnings: &mut Vec<String>,
1884) -> Result<Branch> {
1885 let (cw, cz) = transformer_basis_codes(l1)?;
1895 let from = BusId(id_at(l1, 0, 0)?);
1896 let to = BusId(id_at(l1, 1, 0)?);
1897 let sbase = num_at(l2, 2, system_base)?;
1898 let label = transformer_label(l1);
1899 let (r, x) = convert_transformer_impedance(
1900 num_at(l2, 0, 0.0)?,
1901 num_at(l2, 1, 0.0)?,
1902 sbase,
1903 system_base,
1904 cz,
1905 &label,
1906 "1-2",
1907 warnings,
1908 );
1909 let tap = two_winding_tap(l1, l3, l4, from, to, cw, bus_base_kv, warnings)?;
1910 let modern = raw_rev >= 34;
1911 let (cod_i, cont_i, rma_i) = if modern { (15, 16, 18) } else { (6, 7, 8) };
1912 let cod = int_at(l3, cod_i, 0)?;
1913 let control = (cod != 0)
1914 .then(|| -> Result<TransformerControl> {
1915 let cont = id_at(l3, cont_i, 0)?;
1916 Ok(TransformerControl {
1917 mode: cod_to_mode(cod),
1918 controlled_bus: (cont != 0).then_some(BusId(cont)),
1919 tap_max: num_at(l3, rma_i, 1.1)?,
1920 tap_min: num_at(l3, rma_i + 1, 0.9)?,
1921 band_max: num_at(l3, rma_i + 2, 1.1)?,
1922 band_min: num_at(l3, rma_i + 3, 0.9)?,
1923 ntp: int_at(l3, rma_i + 4, 33)?.clamp(0, i64::from(u32::MAX)) as u32,
1924 mva_base: sbase,
1925 })
1926 })
1927 .transpose()?;
1928 let mag_g = if int_at(l1, 6, 1)? == 1 {
1929 num_at(l1, 7, 0.0)?
1930 } else {
1931 0.0
1932 };
1933 let mag_b = if int_at(l1, 6, 1)? == 1 {
1934 num_at(l1, 8, 0.0)?
1935 } else {
1936 0.0
1937 };
1938 Ok(Branch {
1939 from,
1940 to,
1941 r,
1942 x,
1943 b: mag_b,
1944 charging: Some(BranchCharging {
1945 g_fr: mag_g,
1946 b_fr: mag_b,
1947 g_to: 0.0,
1948 b_to: 0.0,
1949 }),
1950 rate_a: num_at(l3, 3, 0.0)?,
1951 rate_b: num_at(l3, 4, 0.0)?,
1952 rate_c: num_at(l3, 5, 0.0)?,
1953 rating_sets: read_extra_branch_ratings(l3, 3, modern)?,
1954 current_ratings: None,
1955 tap,
1956 shift: num_at(l3, 2, 0.0)?,
1957 in_service: on_at(l1, 11, true)?,
1958 angmin: -360.0,
1959 angmax: 360.0,
1960 control,
1961 solution: None,
1962 uid: None,
1963 extras: Extras::new(),
1964 })
1965}
1966
1967fn cod_to_mode(cod: i64) -> TransformerControlMode {
1971 match cod.abs() {
1972 1 => TransformerControlMode::Voltage,
1973 2 => TransformerControlMode::ReactiveFlow,
1974 3 => TransformerControlMode::ActiveFlow,
1975 _ => TransformerControlMode::Fixed,
1976 }
1977}
1978
1979fn mode_to_cod(mode: TransformerControlMode) -> i64 {
1981 match mode {
1982 TransformerControlMode::Fixed => 0,
1983 TransformerControlMode::Voltage => 1,
1984 TransformerControlMode::ReactiveFlow => 2,
1985 TransformerControlMode::ActiveFlow => 3,
1986 }
1987}
1988
1989#[expect(clippy::too_many_arguments)]
1991fn read_transformer_3w(
1992 l1: &[String],
1993 l2: &[String],
1994 l3: &[String],
1995 l4: &[String],
1996 l5: &[String],
1997 system_base: f64,
1998 bus_base_kv: &BTreeMap<BusId, f64>,
1999 warnings: &mut Vec<String>,
2000) -> Result<Transformer3W> {
2001 let (cw, cz) = transformer_basis_codes(l1)?;
2005 let label = transformer_label(l1);
2006 let buses = [
2007 BusId(id_at(l1, 0, 0)?),
2008 BusId(id_at(l1, 1, 0)?),
2009 BusId(id_at(l1, 2, 0)?),
2010 ];
2011 let z = {
2012 let mut imp = |off: usize, pair: &str| -> Result<Impedance> {
2013 let sbase = num_at(l2, off + 2, system_base)?;
2014 let (r, x) = convert_transformer_impedance(
2015 num_at(l2, off, 0.0)?,
2016 num_at(l2, off + 1, 0.0)?,
2017 sbase,
2018 system_base,
2019 cz,
2020 &label,
2021 pair,
2022 warnings,
2023 );
2024 Ok(Impedance {
2025 r,
2026 x,
2027 base_mva: sbase,
2028 })
2029 };
2030 [imp(0, "1-2")?, imp(3, "2-3")?, imp(6, "3-1")?]
2031 };
2032 let windings = {
2033 let mut winding = |idx: usize, w: &[String]| -> Result<Winding> {
2034 let bus = buses[idx];
2035 let tap = winding_ratio(
2036 w,
2037 bus,
2038 cw,
2039 bus_base_kv,
2040 &label,
2041 match idx {
2042 0 => "winding 1",
2043 1 => "winding 2",
2044 _ => "winding 3",
2045 },
2046 warnings,
2047 )?;
2048 Ok(Winding {
2049 bus,
2050 tap,
2051 shift: num_at(w, 2, 0.0)?,
2052 nominal_kv: num_at(w, 1, 0.0)?,
2053 rate_a: num_at(w, 3, 0.0)?,
2054 rate_b: num_at(w, 4, 0.0)?,
2055 rate_c: num_at(w, 5, 0.0)?,
2056 })
2057 };
2058 [winding(0, l3)?, winding(1, l4)?, winding(2, l5)?]
2059 };
2060 Ok(Transformer3W {
2061 windings,
2062 z,
2063 star_vm: num_at(l2, 9, 1.0)?,
2064 star_va: num_at(l2, 10, 0.0)?,
2065 mag_g: num_at(l1, 7, 0.0)?,
2066 mag_b: num_at(l1, 8, 0.0)?,
2067 in_service: int_at(l1, 11, 1)? != 0,
2070 name: l1
2071 .get(10)
2072 .filter(|n| !n.is_empty())
2073 .map(|n| n.trim().to_string()),
2074 uid: None,
2075 extras: Extras::new(),
2076 })
2077}
2078
2079fn read_dc_line(l1: &[String], rect: &[String], inv: &[String]) -> Result<Hvdc> {
2089 let mdc = int_at(l1, 1, 1)?;
2090 let rdc = num_at(l1, 2, 0.0)?;
2091 let setvl = num_at(l1, 3, 0.0)?;
2092 let vschd = num_at(l1, 4, 0.0)?;
2093 let mut extras = Extras::new();
2094 if let Some(name) = l1.first().filter(|n| !n.is_empty()) {
2095 extras.insert("psse_dc_name".into(), Value::String(name.clone()));
2096 }
2097 extras.insert("psse_dc_mdc".into(), Value::from(mdc));
2098 extras.insert("psse_dc_rdc".into(), jnum(rdc));
2099 extras.insert("psse_dc_vschd".into(), jnum(vschd));
2100 extras.insert("psse_dc_control_tail".into(), tail_array(l1, 5));
2101 extras.insert("psse_dc_rectifier_tail".into(), tail_array(rect, 1));
2102 extras.insert("psse_dc_inverter_tail".into(), tail_array(inv, 1));
2103 Ok(Hvdc {
2104 from: BusId(id_at(rect, 0, 0)?),
2105 to: BusId(id_at(inv, 0, 0)?),
2106 in_service: mdc != 0,
2107 pf: setvl,
2108 pt: setvl,
2109 qf: 0.0,
2110 qt: 0.0,
2111 vf: 1.0,
2112 vt: 1.0,
2113 pmin: 0.0,
2114 pmax: setvl.abs(),
2115 qminf: 0.0,
2116 qmaxf: 0.0,
2117 qmint: 0.0,
2118 qmaxt: 0.0,
2119 loss0: 0.0,
2120 loss1: 0.0,
2121 cost: None,
2122 uid: None,
2123 extras,
2124 })
2125}
2126
2127fn tail_array(f: &[String], start: usize) -> Value {
2129 Value::Array(
2130 f.iter()
2131 .skip(start)
2132 .map(|s| Value::String(s.clone()))
2133 .collect(),
2134 )
2135}
2136
2137fn dc_str(extras: &Extras, key: &str) -> Option<String> {
2139 extras.get(key).and_then(Value::as_str).map(str::to_owned)
2140}
2141
2142fn dc_int(extras: &Extras, key: &str) -> Option<i64> {
2144 extras.get(key).and_then(Value::as_i64)
2145}
2146
2147fn dc_f64(extras: &Extras, key: &str) -> Option<f64> {
2149 extras.get(key).and_then(Value::as_f64)
2150}
2151
2152fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
2154 extras
2155 .get(key)
2156 .and_then(Value::as_f64)
2157 .filter(|v| v.is_finite())
2158}
2159
2160fn extra_i64(extras: &Extras, key: &str) -> Option<i64> {
2162 extras.get(key).and_then(Value::as_i64)
2163}
2164
2165fn same_load_total(a: f64, b: f64) -> bool {
2166 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
2167}
2168
2169fn typed_psse_scal(l: &Load, id: &str, warnings: &mut Vec<String>) -> Option<i64> {
2170 let Some(LoadVoltageModel::Zip {
2171 scaling: Some(scaling),
2172 ..
2173 }) = &l.voltage_model
2174 else {
2175 return None;
2176 };
2177 let scaling = *scaling;
2178 if !scaling.is_finite() {
2179 warnings.push(format!(
2180 "PSS/E load at bus {} id {id:?}: non-finite typed scaling has no SCAL value; used source/default SCAL",
2181 l.bus
2182 ));
2183 return None;
2184 }
2185 let rounded = scaling.round();
2186 if (scaling - rounded).abs() > 1e-9 || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
2187 warnings.push(format!(
2188 "PSS/E load at bus {} id {id:?}: non-integer typed scaling {scaling} has no SCAL value; used source/default SCAL",
2189 l.bus
2190 ));
2191 return None;
2192 }
2193 Some(rounded as i64)
2194}
2195
2196fn typed_psse_load_type(model: &LoadVoltageModel) -> Option<String> {
2197 match model {
2198 LoadVoltageModel::Zip {
2199 load_type: Some(load_type),
2200 ..
2201 } => Some(load_type.to_string()),
2202 _ => None,
2203 }
2204}
2205
2206fn load_components_for_write(
2207 l: &Load,
2208 id: &str,
2209 warnings: &mut Vec<String>,
2210) -> (f64, f64, f64, f64, f64, f64) {
2211 if let Some(LoadVoltageModel::Zip {
2212 p_constant_power,
2213 q_constant_power,
2214 p_constant_current,
2215 q_constant_current,
2216 p_constant_impedance,
2217 q_constant_impedance,
2218 v_nom,
2219 ..
2220 }) = &l.voltage_model
2221 {
2222 if same_load_total(
2223 p_constant_power + p_constant_current + p_constant_impedance,
2224 l.p,
2225 ) && same_load_total(
2226 q_constant_power + q_constant_current + q_constant_impedance,
2227 l.q,
2228 ) {
2229 if v_nom.is_some() {
2230 warnings.push(format!(
2231 "PSS/E load at bus {} id {id:?}: nominal voltage has no load record field; dropped",
2232 l.bus
2233 ));
2234 }
2235 return (
2236 *p_constant_power,
2237 *q_constant_power,
2238 *p_constant_current,
2239 *q_constant_current,
2240 *p_constant_impedance,
2241 *q_constant_impedance,
2242 );
2243 }
2244 warnings.push(format!(
2245 "PSS/E load at bus {} id {id:?}: stale voltage model components did not match \
2246 typed p/q; wrote typed p/q as constant power",
2247 l.bus
2248 ));
2249 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2250 }
2251 if matches!(l.voltage_model, Some(LoadVoltageModel::Exponential { .. })) {
2252 warnings.push(format!(
2253 "PSS/E load at bus {} id {id:?}: exponential voltage model has no load record fields; wrote typed p/q as constant power",
2254 l.bus
2255 ));
2256 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2257 }
2258
2259 let pl = extra_f64(&l.extras, "psse_pl").unwrap_or(l.p);
2260 let ql = extra_f64(&l.extras, "psse_ql").unwrap_or(l.q);
2261 let ip = extra_f64(&l.extras, "psse_ip").unwrap_or(0.0);
2262 let iq = extra_f64(&l.extras, "psse_iq").unwrap_or(0.0);
2263 let yp = extra_f64(&l.extras, "psse_yp").unwrap_or(0.0);
2264 let yq = extra_f64(&l.extras, "psse_yq").unwrap_or(0.0);
2265 let has_components = [
2266 "psse_pl", "psse_ql", "psse_ip", "psse_iq", "psse_yp", "psse_yq",
2267 ]
2268 .iter()
2269 .any(|key| l.extras.contains_key(*key));
2270 if has_components
2271 && (!same_load_total(pl + ip + yp, l.p) || !same_load_total(ql + iq + yq, l.q))
2272 {
2273 warnings.push(format!(
2274 "PSS/E load at bus {} id {id:?}: stale PL/QL/IP/IQ/YP/YQ extras did not match \
2275 typed p/q; wrote typed p/q as constant power",
2276 l.bus
2277 ));
2278 (l.p, l.q, 0.0, 0.0, 0.0, 0.0)
2279 } else {
2280 (pl, ql, ip, iq, yp, yq)
2281 }
2282}
2283
2284fn dc_tail(extras: &Extras, key: &str, default: &str) -> String {
2287 match extras.get(key).and_then(Value::as_array) {
2288 Some(arr) if !arr.is_empty() => arr
2289 .iter()
2290 .filter_map(Value::as_str)
2291 .collect::<Vec<_>>()
2292 .join(", "),
2293 _ => default.to_string(),
2294 }
2295}
2296
2297#[cfg(test)]
2298mod tests {
2299 use super::*;
2300
2301 fn close(actual: f64, expected: f64) {
2302 assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2303 }
2304
2305 fn test_bus(id: usize, kind: BusType) -> Bus {
2306 Bus {
2307 id: BusId(id),
2308 kind,
2309 vm: 1.0,
2310 va: 0.0,
2311 base_kv: 230.0,
2312 vmax: 1.1,
2313 vmin: 0.9,
2314 evhi: None,
2315 evlo: None,
2316 area: 1,
2317 zone: 1,
2318 name: None,
2319 uid: None,
2320 extras: Extras::default(),
2321 }
2322 }
2323
2324 fn branch_with_terminal_charging() -> Branch {
2325 Branch {
2326 from: BusId(1),
2327 to: BusId(2),
2328 r: 0.01,
2329 x: 0.1,
2330 b: 0.0,
2331 charging: Some(BranchCharging {
2332 g_fr: 0.01,
2333 b_fr: 0.02,
2334 g_to: 0.03,
2335 b_to: 0.05,
2336 }),
2337 rate_a: 100.0,
2338 rate_b: 110.0,
2339 rate_c: 120.0,
2340 rating_sets: Vec::new(),
2341 current_ratings: None,
2342 tap: 0.0,
2343 shift: 0.0,
2344 in_service: true,
2345 angmin: -360.0,
2346 angmax: 360.0,
2347 control: None,
2348 solution: None,
2349 uid: None,
2350 extras: Extras::default(),
2351 }
2352 }
2353
2354 fn transformer_with_terminal_charging(charging: BranchCharging) -> Branch {
2355 Branch {
2356 from: BusId(1),
2357 to: BusId(2),
2358 r: 0.01,
2359 x: 0.1,
2360 b: 0.0,
2361 charging: Some(charging),
2362 rate_a: 100.0,
2363 rate_b: 110.0,
2364 rate_c: 120.0,
2365 rating_sets: Vec::new(),
2366 current_ratings: None,
2367 tap: 1.05,
2368 shift: 0.0,
2369 in_service: true,
2370 angmin: -360.0,
2371 angmax: 360.0,
2372 control: None,
2373 solution: None,
2374 uid: None,
2375 extras: Extras::default(),
2376 }
2377 }
2378
2379 fn assert_terminal_charging_round_trip(text: &str) {
2380 let back = parse_psse(text).unwrap();
2381 let charging = back.branches[0].terminal_charging();
2382 close(charging.g_fr, 0.01);
2383 close(charging.b_fr, 0.02);
2384 close(charging.g_to, 0.03);
2385 close(charging.b_to, 0.05);
2386 close(back.branches[0].b, 0.07);
2387 }
2388
2389 #[test]
2390 fn branch_terminal_charging_writes_gi_bi_gj_bj() {
2391 let mut net = Network::in_memory(
2392 "terminal-shunts",
2393 100.0,
2394 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2395 Vec::new(),
2396 );
2397 net.branches.push(branch_with_terminal_charging());
2398
2399 let rev33 = write_psse(&net);
2400 assert!(rev33.warnings.is_empty(), "{:?}", rev33.warnings);
2401 assert_terminal_charging_round_trip(&rev33.text);
2402
2403 let rev35 = write_psse_rev(&net, 35);
2404 assert!(rev35.warnings.is_empty(), "{:?}", rev35.warnings);
2405 assert_terminal_charging_round_trip(&rev35.text);
2406 }
2407
2408 #[test]
2409 fn transformer_magnetizing_admittance_writes_mag1_mag2() {
2410 let mut net = Network::in_memory(
2411 "xfmr-mag",
2412 100.0,
2413 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2414 Vec::new(),
2415 );
2416 net.branches
2417 .push(transformer_with_terminal_charging(BranchCharging {
2418 g_fr: 0.01,
2419 b_fr: 0.02,
2420 g_to: 0.0,
2421 b_to: 0.0,
2422 }));
2423
2424 let conv = write_psse(&net);
2425 assert!(
2426 !conv
2427 .warnings
2428 .iter()
2429 .any(|w| w.contains("magnetizing admittance")),
2430 "{:?}",
2431 conv.warnings
2432 );
2433 let back = parse_psse(&conv.text).unwrap();
2434 let charging = back.branches[0].terminal_charging();
2435 close(charging.g_fr, 0.01);
2436 close(charging.b_fr, 0.02);
2437 close(charging.g_to, 0.0);
2438 close(charging.b_to, 0.0);
2439 close(back.branches[0].b, 0.02);
2440 }
2441
2442 #[test]
2443 fn transformer_to_side_terminal_admittance_warns_and_collapses_to_mag() {
2444 let mut net = Network::in_memory(
2445 "xfmr-mag-collapse",
2446 100.0,
2447 vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2448 Vec::new(),
2449 );
2450 net.branches
2451 .push(transformer_with_terminal_charging(BranchCharging {
2452 g_fr: 0.01,
2453 b_fr: 0.02,
2454 g_to: 0.03,
2455 b_to: 0.05,
2456 }));
2457
2458 let conv = write_psse(&net);
2459 assert!(
2460 conv.warnings
2461 .iter()
2462 .any(|w| w.contains("magnetizing admittance")),
2463 "{:?}",
2464 conv.warnings
2465 );
2466 let back = parse_psse(&conv.text).unwrap();
2467 let charging = back.branches[0].terminal_charging();
2468 close(charging.g_fr, 0.04);
2469 close(charging.b_fr, 0.07);
2470 close(charging.g_to, 0.0);
2471 close(charging.b_to, 0.0);
2472 close(back.branches[0].b, 0.07);
2473 }
2474
2475 #[test]
2476 fn slash_inside_a_quoted_field_is_not_a_comment() {
2477 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2478CASE
2479COMMENT
24801,'A/B ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
24810 / END OF BUS DATA, BEGIN LOAD DATA
2482Q
2483";
2484
2485 let net = parse_psse(raw).unwrap();
2486
2487 assert_eq!(net.buses.len(), 1);
2488 assert_eq!(net.buses[0].name.as_deref(), Some("A/B"));
2489 }
2490
2491 #[test]
2492 fn load_zip_components_are_typed_and_round_trip() {
2493 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2494CASE
2495COMMENT
24960 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
24971,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
24982,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
24990 / END OF BUS DATA, BEGIN LOAD DATA
25002,'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'
25010 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2502Q
2503";
2504 let mut warnings = Vec::new();
2505 let net =
2506 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
2507
2508 assert_eq!(net.loads.len(), 1);
2509 close(net.loads[0].p, 13.0);
2510 close(net.loads[0].q, 5.0);
2511 let Some(LoadVoltageModel::Zip {
2512 p_constant_power,
2513 q_constant_current,
2514 p_constant_impedance,
2515 ..
2516 }) = &net.loads[0].voltage_model
2517 else {
2518 panic!("missing typed ZIP load model");
2519 };
2520 close(*p_constant_power, 10.0);
2521 close(*q_constant_current, 0.5);
2522 close(*p_constant_impedance, 2.0);
2523 assert!(
2524 warnings.iter().any(|w| w.contains("interruptible/DG/flag")),
2525 "missing load option warning: {warnings:?}"
2526 );
2527
2528 let text = write_psse_rev(&net, 35).text;
2529 assert!(
2530 text.contains("10.0, 3.0, 1.0, 0.5, 2.0, 1.5"),
2531 "ZIP components were not replayed: {text}"
2532 );
2533 assert!(
2534 text.contains("4.0, 2.0, 1, 'industrial'"),
2535 "modern load tail was not replayed: {text}"
2536 );
2537 let net2 = parse_psse(&text).unwrap();
2538 close(net2.loads[0].p, 13.0);
2539 close(net2.loads[0].q, 5.0);
2540 }
2541
2542 #[test]
2543 fn tiny_nonzero_zip_components_are_preserved_as_typed_fields() {
2544 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2545CASE
2546COMMENT
25470 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
25481,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25492,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25500 / END OF BUS DATA, BEGIN LOAD DATA
25512,'L1',1,1,1,10.0,3.0,1e-20,0.0,0.0,0.0,1,1,0,0.0,0.0,0,''
25520 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2553Q
2554";
2555 let net = parse_psse(raw).unwrap();
2556 let Some(LoadVoltageModel::Zip {
2557 p_constant_current, ..
2558 }) = &net.loads[0].voltage_model
2559 else {
2560 panic!("tiny nonzero ZIP component was not typed");
2561 };
2562 assert_eq!(p_constant_current.to_bits(), 1.0e-20_f64.to_bits());
2563
2564 let matpower = crate::format::matpower::write_matpower_conversion(&net);
2565 assert!(
2566 matpower
2567 .warnings
2568 .iter()
2569 .any(|w| w.contains("voltage dependent load model")),
2570 "missing MATPOWER voltage model warning: {:?}",
2571 matpower.warnings
2572 );
2573 }
2574
2575 #[test]
2576 fn typed_psse_load_scaling_and_type_write_without_extras() {
2577 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2578CASE
2579COMMENT
25800 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
25811,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25822,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25830 / END OF BUS DATA, BEGIN LOAD DATA
25842,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,1,0,0.0,0.0,0,''
25850 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2586Q
2587";
2588 let mut net = parse_psse(raw).unwrap();
2589 let Some(LoadVoltageModel::Zip {
2590 scaling,
2591 load_type,
2592 v_nom,
2593 ..
2594 }) = &mut net.loads[0].voltage_model
2595 else {
2596 panic!("missing typed ZIP load model");
2597 };
2598 *scaling = Some(0.0);
2599 *load_type = Some(7);
2600 *v_nom = Some(230_000.0);
2601 net.loads[0].extras.remove("psse_scal");
2602 net.loads[0].extras.remove("psse_loadtype");
2603
2604 let conv = write_psse_rev(&net, 35);
2605
2606 assert!(
2607 conv.text.contains(", 1, 0, 0, 0.0, 0.0, 0, '7'"),
2608 "typed SCAL/LOADTYPE were not written: {}",
2609 conv.text
2610 );
2611 assert!(
2612 conv.warnings.iter().any(|w| w.contains("nominal voltage")),
2613 "missing nominal voltage warning: {:?}",
2614 conv.warnings
2615 );
2616 let rev33 = write_psse(&net);
2617 assert!(
2618 rev33
2619 .warnings
2620 .iter()
2621 .any(|w| w.contains("load type requires revision 35")),
2622 "missing rev33 load type warning: {:?}",
2623 rev33.warnings
2624 );
2625 let reparsed = parse_psse(&conv.text).unwrap();
2626 let Some(LoadVoltageModel::Zip {
2627 scaling, load_type, ..
2628 }) = &reparsed.loads[0].voltage_model
2629 else {
2630 panic!("missing reparsed ZIP load model");
2631 };
2632 assert_eq!(*scaling, Some(0.0));
2633 assert_eq!(*load_type, Some(7));
2634 }
2635
2636 #[test]
2637 fn mutated_load_does_not_replay_stale_psse_zip_extras() {
2638 let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2639CASE
2640COMMENT
26410 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
26421,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26432,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26440 / END OF BUS DATA, BEGIN LOAD DATA
26452,'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'
26460 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2647Q
2648";
2649 let mut net = parse_psse(raw).unwrap();
2650 net.loads[0].p = 20.0;
2651 net.loads[0].q = 7.0;
2652
2653 let conv = write_psse_rev(&net, 35);
2654
2655 assert!(
2656 conv.text.contains("20.0, 7.0, 0.0, 0.0, 0.0, 0.0"),
2657 "typed p/q were not written as constant power: {}",
2658 conv.text
2659 );
2660 assert!(
2661 conv.warnings
2662 .iter()
2663 .any(|w| w.contains("stale voltage model components")),
2664 "missing stale voltage model warning: {:?}",
2665 conv.warnings
2666 );
2667 let reparsed = parse_psse(&conv.text).unwrap();
2668 close(reparsed.loads[0].p, 20.0);
2669 close(reparsed.loads[0].q, 7.0);
2670 }
2671
2672 #[test]
2673 fn transformer_continuation_rejects_section_terminator() {
2674 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2675CASE
2676COMMENT
26771,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26782,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26790 / END OF BUS DATA, BEGIN LOAD DATA
26800 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
26810 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
26820 / END OF GENERATOR DATA, BEGIN BRANCH DATA
26830 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
26841,2,0,'1 ',1,1,1,0,0,1,'xf'
26850 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2686Q
2687";
2688 let err = parse_psse(raw).unwrap_err().to_string();
2689 assert!(
2690 err.contains("transformer record ended before transformer impedance line"),
2691 "{err}"
2692 );
2693 }
2694
2695 #[test]
2696 fn transformer_impedance_line_can_start_with_zero_resistance() {
2697 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2698CASE
2699COMMENT
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
27030 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27040 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27050 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27060 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27071,2,0,'1 ',1,1,1,0,0,1,'xf',1
27080,0.10,100.0
27091.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27101.0,230.0
27110 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2712Q
2713";
2714 let net = parse_psse(raw).unwrap();
2715
2716 assert_eq!(net.branches.len(), 1);
2717 close(net.branches[0].r, 0.0);
2718 close(net.branches[0].x, 0.10);
2719 }
2720
2721 #[test]
2722 fn transformer_non_integral_cz_is_a_hard_error() {
2723 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2727CASE
2728COMMENT
27291,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27302,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27310 / END OF BUS DATA, BEGIN LOAD DATA
27320 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27330 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27340 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27350 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27361,2,0,'1 ',1,2.9,1,0,0,1,'xf',1
27370,0.10,100.0
27381.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27391.0,230.0
27400 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2741Q
2742";
2743 let err = parse_psse(raw).unwrap_err().to_string();
2744 assert!(err.contains("field 5") && err.contains("2.9"), "{err}");
2745 }
2746
2747 #[test]
2748 fn non_unit_two_winding_transformer_bases_are_converted() {
2749 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2750CASE
2751COMMENT
27521,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27532,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27540 / END OF BUS DATA, BEGIN LOAD DATA
27550 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27560 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27570 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27580 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27591,2,0,'1 ',2,2,1,0,0,1,'xf',1
27600.01,0.10,50.0
2761241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2762115.0,115.0
27630 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2764Q
2765";
2766 let parsed = crate::parse_str(raw, "psse").unwrap();
2767 assert!(
2768 !parsed
2769 .warnings
2770 .iter()
2771 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2772 "unexpected transformer base warning: {:?}",
2773 parsed.warnings
2774 );
2775 let br = &parsed.network.branches[0];
2776 close(br.r, 0.02);
2777 close(br.x, 0.20);
2778 close(br.tap, 1.05);
2779 }
2780
2781 #[test]
2782 fn cz3_load_loss_and_cw3_nominal_voltage_are_converted() {
2783 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2784CASE
2785COMMENT
27861,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27872,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27880 / END OF BUS DATA, BEGIN LOAD DATA
27890 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27900 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27910 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27920 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27931,2,0,'1 ',3,3,1,0,0,1,'xf',1
2794250000.0,0.10,50.0
27951.05,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27961.0,115.0
27970 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2798Q
2799";
2800 let parsed = crate::parse_str(raw, "psse").unwrap();
2801 assert!(
2802 !parsed
2803 .warnings
2804 .iter()
2805 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2806 "unexpected transformer base warning: {:?}",
2807 parsed.warnings
2808 );
2809 let br = &parsed.network.branches[0];
2810 close(br.r, 0.01);
2811 close(br.x, (0.10_f64 * 0.10 - 0.005_f64 * 0.005).sqrt() * 2.0);
2812 close(br.tap, 1.05);
2813 }
2814
2815 #[test]
2816 fn non_unit_three_winding_transformer_bases_are_converted() {
2817 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2818CASE
2819COMMENT
28201,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28212,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28223,'BUS3 ', 13.8,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28230 / END OF BUS DATA, BEGIN LOAD DATA
28240 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28250 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28260 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28270 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28281,2,3,'1 ',2,2,1,0,0,1,'xf3',1
28290.01,0.10,50.0,0.02,0.20,100.0,0.03,0.30,200.0,1.0,0.0
2830241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2831115.0,115.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
283213.8,13.8,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
28330 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2834Q
2835";
2836 let parsed = crate::parse_str(raw, "psse").unwrap();
2837 assert!(
2838 !parsed
2839 .warnings
2840 .iter()
2841 .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2842 "unexpected transformer base warning: {:?}",
2843 parsed.warnings
2844 );
2845 let t = &parsed.network.transformers_3w[0];
2846 close(t.z[0].r, 0.02);
2847 close(t.z[0].x, 0.20);
2848 close(t.z[1].r, 0.02);
2849 close(t.z[1].x, 0.20);
2850 close(t.z[2].r, 0.015);
2851 close(t.z[2].x, 0.15);
2852 close(t.windings[0].tap, 1.05);
2853 close(t.windings[1].tap, 1.0);
2854 close(t.windings[2].tap, 1.0);
2855 }
2856
2857 #[test]
2858 fn dc_continuation_rejects_section_terminator() {
2859 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2860CASE
2861COMMENT
28620 / END OF SYSTEM-WIDE DATA, BEGIN TWO-TERMINAL DC DATA
2863'DC1',1
28640 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
2865Q
2866";
2867 let err = parse_psse(raw).unwrap_err().to_string();
2868 assert!(
2869 err.contains("two-terminal DC record ended before rectifier line"),
2870 "{err}"
2871 );
2872 }
2873
2874 #[test]
2875 fn reads_comment_headers_system_wide_block_and_named_branch_records() {
2876 let raw = r#"@!IC, SBASE,REV,XFRRAT,NXFRAT,BASFRQ
28770, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2878
2879
2880GENERAL, THRSHZ=0.0002
2881RATING, 1, " ", " "
28820 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
2883@! I,'NAME ', BASKV, IDE,AREA,ZONE,OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO
28841,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
28852,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
28860 / END OF BUS DATA, BEGIN LOAD DATA
2887@! I,'ID',STAT,AREA,ZONE, PL, QL
28882,'1 ',1,1,1,10.0,5.0
28890 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28900 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
2891@! I,'ID', PG, QG, QT, QB, VS, IREG, MBASE, ZR, ZX, RT, XT, GTAP,STAT, RMPCT, PT, PB
28921,'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
28930 / END OF GENERATOR DATA, BEGIN BRANCH DATA
2894@! 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
28951,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
28960 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28970 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2898Q
2899"#;
2900
2901 let mut net = parse_psse(raw).unwrap();
2902
2903 close(net.base_mva, 100.0);
2904 assert_eq!(net.buses.len(), 2);
2905 assert_eq!(net.loads.len(), 1);
2906 assert_eq!(net.generators.len(), 1);
2907 assert_eq!(net.branches.len(), 1);
2908 close(net.branches[0].rate_a, 100.0);
2909 assert_eq!(net.branches[0].rating_sets.len(), 2);
2910 assert_eq!(net.branches[0].rating_sets[0].name, "RATE4");
2911 close(net.branches[0].rating_sets[0].rate_mva, 70.0);
2912 assert_eq!(net.branches[0].rating_sets[1].name, "RATE6");
2913 close(net.branches[0].rating_sets[1].rate_mva, 60.0);
2914 assert!(net.branches[0].in_service);
2915
2916 net.source = None;
2917 let written = write_psse_rev(&net, 34);
2918 assert!(
2919 !written.warnings.iter().any(|w| w.contains("rating set")),
2920 "v34 should carry RATE4-RATE12, got {:?}",
2921 written.warnings
2922 );
2923 let back = parse_psse(&written.text).unwrap();
2924 assert_eq!(back.branches[0].rating_sets.len(), 2);
2925 assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
2926 close(back.branches[0].rating_sets[0].rate_mva, 70.0);
2927 assert_eq!(back.branches[0].rating_sets[1].name, "RATE6");
2928 close(back.branches[0].rating_sets[1].rate_mva, 60.0);
2929 }
2930
2931 #[test]
2932 fn v34_transformer_reads_float_k_and_modern_winding_columns() {
2933 let raw = r"0, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2939CASE
2940COMMENT
29410 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
29421,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
29432,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
29440 / END OF BUS DATA, BEGIN LOAD DATA
29450 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
29460 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
29470 / END OF GENERATOR DATA, BEGIN BRANCH DATA
29480 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
29491, 2, 0.00, '1', 1, 1, 1, 0.0, 0.0, 2, 'T1 ', 1, 1, 1.0, 0, 1, 0, 1, 0, 1, ' '
29500.01, 0.10, 100.0
29511.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
29521.0, 0.0
29530 / END OF TRANSFORMER DATA, BEGIN AREA DATA
29541, 1, 0.0, 0.0, 'AREA '
2955Q
2956";
2957 let net = parse_psse(raw).unwrap();
2958 assert_eq!(net.branches.len(), 1, "K = 0.00 is a 2-winding record");
2959 assert!(net.transformers_3w.is_empty());
2960 assert_eq!(
2961 net.areas.len(),
2962 1,
2963 "the section after the transformer parsed"
2964 );
2965 let br = &net.branches[0];
2966 close(br.tap, 1.05);
2967 close(br.rate_a, 100.0);
2968 assert_eq!(br.rating_sets.len(), 1);
2969 assert_eq!(br.rating_sets[0].name, "RATE4");
2970 close(br.rating_sets[0].rate_mva, 70.0);
2971 let c = br.control.as_ref().expect("COD at 15 marks the control");
2972 assert_eq!(c.mode, TransformerControlMode::Voltage);
2973 assert_eq!(c.controlled_bus, Some(BusId(2)));
2974 close(c.tap_max, 1.08);
2975 close(c.tap_min, 0.92);
2976 close(c.band_max, 1.05);
2977 close(c.band_min, 0.98);
2978 assert_eq!(c.ntp, 17);
2979 }
2980
2981 #[test]
2982 fn v34_warns_when_custom_rating_name_is_emitted_as_rate_slot() {
2983 let mut net = Network::in_memory(
2984 "ratings",
2985 100.0,
2986 vec![
2987 Bus::new(BusId(1), BusType::Ref, 230.0),
2988 Bus::new(BusId(2), BusType::Pq, 230.0),
2989 ],
2990 Vec::new(),
2991 );
2992 let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.05);
2993 branch.rate_a = 100.0;
2994 branch
2995 .rating_sets
2996 .push(BranchRatingSet::new("emergency", 125.0));
2997 net.branches.push(branch);
2998
2999 let written = write_psse_rev(&net, 34);
3000
3001 assert!(
3002 written.warnings.iter().any(|w| {
3003 w.contains("rating set emergency=125")
3004 && w.contains("emitted as RATE4")
3005 && w.contains("names outside RATE4-RATE12 are not preserved")
3006 }),
3007 "missing rating rename warning: {:?}",
3008 written.warnings
3009 );
3010 let back = parse_psse(&written.text).unwrap();
3011 assert_eq!(back.branches[0].rating_sets.len(), 1);
3012 assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
3013 close(back.branches[0].rating_sets[0].rate_mva, 125.0);
3014 }
3015
3016 #[test]
3017 fn reads_start_of_section_markers_and_gen_alias() {
3018 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3019CASE
3020COMMENT
30211,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30222,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30230 / End of Bus Data, Start of Load Data
30242,'1 ',1,1,1,10.0,5.0
30250 / End of Load Data, Start of Fixed Shunt Data
30260 / End of Fixed Shunt Data, Start of Gen Data
30271,'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
30280 / End of Gen Data, Start of Branch Data
30291,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
30300 / End of Branch Data, Start of Transformer Data
30310 / End of Transformer Data, Start of Area Interchange Data
3032Q
3033";
3034
3035 let net = parse_psse(raw).unwrap();
3036
3037 assert_eq!(net.buses.len(), 2);
3038 assert_eq!(net.loads.len(), 1);
3039 assert_eq!(net.generators.len(), 1);
3040 assert_eq!(net.branches.len(), 1);
3041 }
3042
3043 #[test]
3044 fn v33_long_branch_with_blank_ratea_keeps_v33_columns() {
3045 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3046CASE
3047COMMENT
30481,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30492,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30500 / END OF BUS DATA, BEGIN LOAD DATA
30510 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
30520 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
30530 / END OF GENERATOR DATA, BEGIN BRANCH DATA
30541,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
30550 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
30560 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3057Q
3058";
3059
3060 let net = parse_psse(raw).unwrap();
3061
3062 assert_eq!(net.branches.len(), 1);
3063 close(net.branches[0].rate_a, 0.0);
3064 close(net.branches[0].rate_b, 90.0);
3065 close(net.branches[0].rate_c, 80.0);
3066 assert!(net.branches[0].in_service);
3067 }
3068
3069 #[test]
3070 fn captured_load_ids_round_trip_and_parallel_loads_stay_distinct() {
3071 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3072CASE
3073COMMENT
30741,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30752,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30760 / END OF BUS DATA, BEGIN LOAD DATA
30772,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
30782,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
30790 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
30800 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
30810 / END OF GENERATOR DATA, BEGIN BRANCH DATA
30820 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
30830 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3084Q
3085";
3086 let id = |l: &Load| {
3087 l.extras
3088 .get("id")
3089 .and_then(|v| v.as_str())
3090 .map(str::to_owned)
3091 };
3092 let net = parse_psse(raw).unwrap();
3093 assert_eq!(net.loads.len(), 2);
3094 assert_eq!(id(&net.loads[0]).as_deref(), Some("A"));
3095 assert_eq!(id(&net.loads[1]).as_deref(), Some("B"));
3096
3097 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3099 assert_eq!(id(&net2.loads[0]).as_deref(), Some("A"));
3100 assert_eq!(id(&net2.loads[1]).as_deref(), Some("B"));
3101
3102 let mut synth = net.clone();
3106 for l in &mut synth.loads {
3107 l.extras.remove("id");
3108 }
3109 let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3110 let ids: Vec<_> = net3.loads.iter().filter_map(&id).collect();
3111 assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3112 }
3113
3114 #[test]
3115 fn sanitized_load_ids_are_allocated_after_cleaning() {
3116 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3117CASE
3118COMMENT
31191,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31202,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31210 / END OF BUS DATA, BEGIN LOAD DATA
31222,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
31232,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
31240 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31250 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31260 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31270 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31280 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3129Q
3130";
3131 let mut net = parse_psse(raw).unwrap();
3132 net.loads[0]
3133 .extras
3134 .insert("id".into(), Value::String("A/B".into()));
3135 net.loads[1]
3136 .extras
3137 .insert("id".into(), Value::String("A'B".into()));
3138
3139 let conv = write_psse(&net);
3140 let reparsed = parse_psse(&conv.text).unwrap();
3141 let ids: Vec<_> = reparsed
3142 .loads
3143 .iter()
3144 .filter_map(|l| l.extras.get("id").and_then(Value::as_str))
3145 .collect();
3146
3147 assert_eq!(ids, vec!["A B", "1"]);
3148 assert!(
3149 conv.warnings
3150 .iter()
3151 .any(|w| w.contains("2 quoted PSS/E field")),
3152 "missing sanitation warning: {:?}",
3153 conv.warnings
3154 );
3155 }
3156
3157 #[test]
3158 fn two_winding_transformer_charging_round_trips_via_mag2() {
3159 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3162CASE
3163COMMENT
31641,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
31652,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
31660 / END OF BUS DATA, BEGIN LOAD DATA
31670 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31680 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31690 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31700 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31711, 2, 0, '1', 1, 1, 1, 0, 0.04, 2, 'XF ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
31720.01, 0.10, 100.0
31731.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
31741.0, 0
31750 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3176Q
3177";
3178 let net = parse_psse(raw).unwrap();
3179 assert_eq!(net.branches.len(), 1);
3180 assert!(net.branches[0].is_transformer());
3181 close(net.branches[0].b, 0.04);
3182
3183 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3184 close(net2.branches[0].b, 0.04);
3185 }
3186
3187 #[test]
3188 fn parallel_branches_round_trip_and_stay_distinct() {
3189 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3192CASE
3193COMMENT
31941,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31952,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31960 / END OF BUS DATA, BEGIN LOAD DATA
31970 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31980 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31990 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32001,2,'1 ',0.01,0.05,0.001,0,0,0,0,0,0,0,1,1,0.0
32011,2,'2 ',0.02,0.06,0.002,0,0,0,0,0,0,0,1,1,0.0
32020 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32030 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3204Q
3205";
3206 let ckt = |b: &Branch| {
3207 b.extras
3208 .get("id")
3209 .and_then(|v| v.as_str())
3210 .map(str::to_owned)
3211 };
3212 let net = parse_psse(raw).unwrap();
3213 assert_eq!(net.branches.len(), 2);
3214 assert_eq!(ckt(&net.branches[0]).as_deref(), Some("1"));
3215 assert_eq!(ckt(&net.branches[1]).as_deref(), Some("2"));
3216
3217 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3219 assert_eq!(net2.branches.len(), 2);
3220 assert_eq!(ckt(&net2.branches[0]).as_deref(), Some("1"));
3221 assert_eq!(ckt(&net2.branches[1]).as_deref(), Some("2"));
3222
3223 let mut synth = net.clone();
3226 for b in &mut synth.branches {
3227 b.extras.remove("id");
3228 }
3229 let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3230 let ids: Vec<_> = net3.branches.iter().filter_map(&ckt).collect();
3231 assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3232 }
3233
3234 #[test]
3235 fn reads_and_writes_solver_params() {
3236 let raw = r"0, 100.00, 34, 0, 1, 60.00 / x
3237CASE
3238COMMENT
3239GENERAL, THRSHZ=0.0001
3240NEWTON, TOLN=0.1, ITMXN=25
3241SOLVER, ACTAPS=1, AREAIN=0, PHSHFT=1, DCTAPS=1, SWSHNT=0
32420 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
32431,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32440 / END OF BUS DATA, BEGIN LOAD DATA
3245Q
3246";
3247 let net = parse_psse(raw).unwrap();
3248 let sp = net.solver.as_ref().expect("solver params parsed");
3249 close(sp.zero_impedance_threshold.unwrap(), 0.0001);
3250 close(sp.newton_tolerance.unwrap(), 0.1);
3251 assert_eq!(sp.max_iterations, Some(25));
3252 assert_eq!(sp.adjust_taps, Some(true));
3253 assert_eq!(sp.adjust_area_interchange, Some(false));
3254 assert_eq!(sp.adjust_phase_shift, Some(true));
3255 assert_eq!(sp.adjust_switched_shunt, Some(false));
3256
3257 let net2 = parse_psse(&write_psse_rev(&net, 34).text).unwrap();
3259 let sp2 = net2
3260 .solver
3261 .as_ref()
3262 .expect("solver params survive the write");
3263 close(sp2.newton_tolerance.unwrap(), 0.1);
3264 assert_eq!(sp2.max_iterations, Some(25));
3265 assert_eq!(sp2.adjust_taps, Some(true));
3266 assert_eq!(sp2.adjust_area_interchange, Some(false));
3267 }
3268
3269 #[test]
3270 fn reads_and_writes_area_records() {
3271 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3272CASE
3273COMMENT
32741,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32755,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32760 / END OF BUS DATA, BEGIN LOAD DATA
32770 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
32780 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
32790 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32800 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32810 / END OF TRANSFORMER DATA, BEGIN AREA DATA
32821, 5, 100.0, 10.0, 'AREA-ONE '
32830 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3284Q
3285";
3286 let net = parse_psse(raw).unwrap();
3287 assert_eq!(net.areas.len(), 1, "the area record was read");
3288 let a = &net.areas[0];
3289 assert_eq!(a.number, 1);
3290 assert_eq!(a.slack_bus, Some(BusId(5)));
3291 close(a.net_interchange, 100.0);
3292 close(a.tolerance, 10.0);
3293 assert_eq!(a.name.as_deref(), Some("AREA-ONE"));
3294
3295 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3297 assert_eq!(net2.areas.len(), 1);
3298 let a2 = &net2.areas[0];
3299 assert_eq!(a2.number, 1);
3300 assert_eq!(a2.slack_bus, Some(BusId(5)));
3301 close(a2.net_interchange, 100.0);
3302 assert_eq!(a2.name.as_deref(), Some("AREA-ONE"));
3303 }
3304
3305 #[test]
3306 fn reads_and_writes_a_switched_shunt() {
3307 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3308CASE
3309COMMENT
33101,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33113,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33127,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33130 / END OF BUS DATA, BEGIN LOAD DATA
33140 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33150 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33160 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33170 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33180 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33190 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
33203, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
33210 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3322Q
3323";
3324 let net = parse_psse(raw).unwrap();
3325 assert_eq!(net.shunts.len(), 1);
3326 let sh = &net.shunts[0];
3327 assert_eq!(sh.bus, BusId(3));
3328 close(sh.b, 19.0);
3329 let c = sh.control.as_ref().expect("switched-shunt control parsed");
3330 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3331 close(c.vhigh, 1.05);
3332 close(c.vlow, 0.95);
3333 assert_eq!(c.control_bus, Some(BusId(7)));
3334 close(c.rmpct, 100.0);
3335 assert_eq!(c.blocks.len(), 2);
3336 assert_eq!(c.blocks[0].steps, 2);
3337 close(c.blocks[0].b, 25.0);
3338 assert_eq!(c.blocks[1].steps, 1);
3339 close(c.blocks[1].b, 50.0);
3340
3341 let text = write_psse(&net).text;
3343 assert!(text.contains("BEGIN SWITCHED SHUNT DATA"));
3344 let net2 = parse_psse(&text).unwrap();
3345 assert_eq!(net2.shunts.len(), 1);
3346 let c2 = net2.shunts[0]
3347 .control
3348 .as_ref()
3349 .expect("control survives the write");
3350 assert_eq!(c2.mode, SwitchedShuntMode::Discrete);
3351 assert_eq!(c2.control_bus, Some(BusId(7)));
3352 assert_eq!(c2.blocks.len(), 2);
3353 close(c2.blocks[0].b, 25.0);
3354 close(net2.shunts[0].b, 19.0);
3355 }
3356
3357 #[test]
3358 fn v35_switched_shunt_write_round_trips_through_the_id_column() {
3359 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3364CASE
3365COMMENT
33663,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33677,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33680 / END OF BUS DATA, BEGIN LOAD DATA
33690 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33700 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33710 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33720 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33730 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33740 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
33753, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
33760 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3377Q
3378";
3379 let net = parse_psse(raw).unwrap();
3380 let text = write_psse_rev(&net, 35).text;
3381 let net2 = parse_psse(&text).unwrap();
3382 assert_eq!(net2.shunts.len(), 1);
3383 let sh = &net2.shunts[0];
3384 assert_eq!(sh.bus, BusId(3));
3385 close(sh.b, 19.0);
3386 let c = sh
3387 .control
3388 .as_ref()
3389 .expect("v35 switched-shunt control survives the write");
3390 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3391 close(c.vhigh, 1.05);
3392 close(c.vlow, 0.95);
3393 assert_eq!(c.control_bus, Some(BusId(7)));
3394 close(c.rmpct, 100.0);
3395 assert_eq!(c.blocks.len(), 2);
3396 close(c.blocks[0].b, 25.0);
3397 close(c.blocks[1].b, 50.0);
3398 }
3399
3400 #[test]
3401 fn reads_and_writes_a_generator_remote_regulated_bus() {
3402 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3403CASE
3404COMMENT
34051,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34063,'B3 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34077,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34080 / END OF BUS DATA, BEGIN LOAD DATA
34090 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34100 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34113,'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
34121,'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
34130 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34140 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34150 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3416Q
3417";
3418 let net = parse_psse(raw).unwrap();
3419 assert_eq!(net.generators.len(), 2);
3420 let g3 = net.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3421 assert_eq!(
3422 g3.regulated_bus,
3423 Some(BusId(7)),
3424 "IREG names the remote regulated bus"
3425 );
3426 let g1 = net.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3428 assert_eq!(g1.regulated_bus, None);
3429
3430 let text = write_psse(&net).text;
3432 let net2 = parse_psse(&text).unwrap();
3433 let g3b = net2.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3434 assert_eq!(g3b.regulated_bus, Some(BusId(7)));
3435 let g1b = net2.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3436 assert_eq!(g1b.regulated_bus, None);
3437 }
3438
3439 #[test]
3440 fn reads_a_v35_generator_record_with_nreg() {
3441 let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3446CASE
3447COMMENT
34481,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34490 / END OF BUS DATA, BEGIN LOAD DATA
34500 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34510 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34521,'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
34530 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34540 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34550 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3456Q
3457";
3458 let net = parse_psse(raw).unwrap();
3459 assert_eq!(net.generators.len(), 1);
3460 let g = &net.generators[0];
3461 close(g.mbase, 900.0);
3462 assert!(!g.in_service, "STAT = 0 at the shifted index");
3463 close(g.pmax, 80.0);
3464 close(g.pmin, 10.0);
3465 assert_eq!(g.regulated_bus, None, "IREG stays at field 7");
3466
3467 let net2 = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
3469 let g2 = &net2.generators[0];
3470 close(g2.mbase, 900.0);
3471 assert!(!g2.in_service);
3472 close(g2.pmax, 80.0);
3473 close(g2.pmin, 10.0);
3474 }
3475
3476 #[test]
3477 fn stale_control_pointers_warn_and_drop() {
3478 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3479CASE
3480COMMENT
34811,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34822,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34830 / END OF BUS DATA, BEGIN LOAD DATA
34840 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34850 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34861,'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
34870 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34880 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34891, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
34900.01, 0.10, 100.0
34911.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
34921.0, 0
34930 / END OF TRANSFORMER DATA, BEGIN AREA DATA
34941, 97, 0.0, 0.0, 'AREA '
34950 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
34960 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
34970 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
34980 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
34990 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
35000 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
35010 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
35020 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
35030 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA
35040 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA
35052, 2, 0, 1, 1.05, 0.95, 96, 100.0, '', 19.0, 2, 25.0
35060 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3507Q
3508";
3509 let mut warnings = Vec::new();
3510 let net =
3511 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3512
3513 assert_eq!(net.generators[0].regulated_bus, None);
3514 assert_eq!(
3515 net.branches[0]
3516 .control
3517 .as_ref()
3518 .and_then(|c| c.controlled_bus),
3519 None
3520 );
3521 assert_eq!(
3522 net.shunts[0].control.as_ref().and_then(|c| c.control_bus),
3523 None
3524 );
3525 assert_eq!(net.areas[0].slack_bus, None);
3526 assert!(
3527 warnings.iter().any(|w| w.contains("GENERATOR DATA")
3528 && w.contains("IREG")
3529 && w.contains("missing bus id 99")),
3530 "missing IREG warning: {warnings:?}"
3531 );
3532 assert!(
3533 warnings.iter().any(|w| w.contains("TRANSFORMER DATA")
3534 && w.contains("CONT")
3535 && w.contains("missing bus id 98")),
3536 "missing CONT warning: {warnings:?}"
3537 );
3538 assert!(
3539 warnings.iter().any(|w| w.contains("SWITCHED SHUNT DATA")
3540 && w.contains("SWREM")
3541 && w.contains("missing bus id 96")),
3542 "missing SWREM warning: {warnings:?}"
3543 );
3544 assert!(
3545 warnings.iter().any(|w| w.contains("AREA DATA")
3546 && w.contains("ISW")
3547 && w.contains("missing bus id 97")),
3548 "missing ISW warning: {warnings:?}"
3549 );
3550 }
3551
3552 #[test]
3553 fn truncated_transformer_continuation_names_expected_line() {
3554 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3555CASE
3556COMMENT
35571,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35582,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35590 / END OF BUS DATA, BEGIN LOAD DATA
35600 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35610 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35620 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35630 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35641, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
35650 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3566Q
3567";
3568 let err = parse_psse(raw).unwrap_err().to_string();
3569 assert!(
3570 err.contains("transformer record ended before transformer impedance line"),
3571 "got {err}"
3572 );
3573 }
3574
3575 #[test]
3576 fn unmodeled_section_counts_skip_bare_terminators() {
3577 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3578CASE
3579COMMENT
35801,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35810 / END OF BUS DATA, BEGIN LOAD DATA
35820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35860 / END OF TRANSFORMER DATA, BEGIN AREA DATA
35870 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
35880 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3589'VSC1', 1
35902, 3
35910
35920 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
3593Q
3594";
3595 let mut warnings = Vec::new();
3596 parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3597 assert!(
3598 warnings
3599 .iter()
3600 .any(|w| w.contains("VSC DC LINE section (2 record line(s))")),
3601 "bare terminator should not be counted as skipped data: {warnings:?}"
3602 );
3603 }
3604
3605 #[test]
3606 fn reads_a_v35_switched_shunt_with_an_id_column() {
3607 let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3612CASE
3613COMMENT
36145,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36157,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36160 / END OF BUS DATA, BEGIN LOAD DATA
36170 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36180 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36190 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36200 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36210 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36220 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
36235,'1 ',2,0,1,1.05,0.95,7,3,80.0,'',19.0,1,2,25.0,0,1,50.0
36240 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3625Q
3626";
3627 let net = parse_psse(raw).unwrap();
3628 assert_eq!(net.shunts.len(), 1);
3629 let sh = &net.shunts[0];
3630 assert_eq!(sh.bus, BusId(5));
3631 close(sh.b, 19.0);
3632 assert!(sh.in_service);
3633 let c = sh.control.as_ref().expect("switched-shunt control parsed");
3634 assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3635 close(c.vhigh, 1.05);
3636 close(c.vlow, 0.95);
3637 assert_eq!(
3638 c.control_bus,
3639 Some(BusId(7)),
3640 "SWREG at field 7, not NREG at 8"
3641 );
3642 close(c.rmpct, 80.0);
3643 assert_eq!(c.blocks.len(), 2);
3645 assert_eq!(c.blocks[0].steps, 2);
3646 close(c.blocks[0].b, 25.0);
3647 assert_eq!(c.blocks[1].steps, 1);
3648 close(c.blocks[1].b, 50.0);
3649 }
3650
3651 #[test]
3652 fn reads_and_writes_a_two_terminal_dc_line() {
3653 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3654CASE
3655COMMENT
36561,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36574,'B4 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36585,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36590 / END OF BUS DATA, BEGIN LOAD DATA
36600 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36610 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36620 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36630 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36640 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36650 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3666'DCLINE1', 1, 2.5, 350.0, 500.0, 0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0
36674, 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
36685, 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
36690 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3670Q
3671";
3672 let net = parse_psse(raw).unwrap();
3673 assert_eq!(net.hvdc.len(), 1, "the two-terminal DC line was read");
3674 let dc = &net.hvdc[0];
3675 assert_eq!(dc.from, BusId(4), "rectifier bus is the from end");
3676 assert_eq!(dc.to, BusId(5), "inverter bus is the to end");
3677 assert!(dc.in_service);
3678 close(dc.pf, 350.0);
3679 close(dc.pt, 350.0);
3680
3681 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3683 assert_eq!(net2.hvdc.len(), 1, "the DC line survives the write");
3684 let dc2 = &net2.hvdc[0];
3685 assert_eq!(dc2.from, BusId(4));
3686 assert_eq!(dc2.to, BusId(5));
3687 assert!(dc2.in_service);
3688 close(dc2.pf, 350.0);
3689 }
3690
3691 #[test]
3692 fn reads_and_writes_a_regulating_transformer_control() {
3693 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3694CASE
3695COMMENT
36961,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
36972,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
36983,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
36990 / END OF BUS DATA, BEGIN LOAD DATA
37000 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37010 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37020 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37030 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37041, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
37050.01, 0.10, 100.0
37061.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
37071.0, 0
37080 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3709Q
3710";
3711 let net = parse_psse(raw).unwrap();
3712 assert_eq!(net.branches.len(), 1);
3713 let c = net.branches[0].control.as_ref().expect("control parsed");
3714 assert_eq!(c.mode, TransformerControlMode::Voltage);
3715 assert_eq!(c.controlled_bus, Some(BusId(3)));
3716 close(c.tap_max, 1.08);
3717 close(c.tap_min, 0.92);
3718 close(c.band_min, 0.98);
3719 assert_eq!(c.ntp, 17);
3720 close(c.mva_base, 100.0);
3721
3722 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3724 let c2 = net2.branches[0].control.as_ref().expect("control survives");
3725 assert_eq!(c2.mode, TransformerControlMode::Voltage);
3726 assert_eq!(c2.controlled_bus, Some(BusId(3)));
3727 close(c2.tap_max, 1.08);
3728 assert_eq!(c2.ntp, 17);
3729 close(net2.branches[0].tap, 1.025);
3730 close(net2.branches[0].shift, 2.5);
3731 }
3732
3733 #[test]
3734 fn reads_and_writes_a_three_winding_transformer() {
3735 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3736CASE
3737COMMENT
37381,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37392,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37403,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37410 / END OF BUS DATA, BEGIN LOAD DATA
37420 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37430 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37440 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37450 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37461, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
37470.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
37481.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
37491.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
37500.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
37510 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3752Q
3753";
3754 let net = parse_psse(raw).unwrap();
3755 assert_eq!(
3756 net.transformers_3w.len(),
3757 1,
3758 "the 3-winding record was read"
3759 );
3760 assert!(net.branches.is_empty(), "a 3W is not folded into branches");
3761 let t = &net.transformers_3w[0];
3762 assert_eq!(
3763 [t.windings[0].bus, t.windings[1].bus, t.windings[2].bus],
3764 [BusId(1), BusId(2), BusId(3)]
3765 );
3766 close(t.z[0].r, 0.01);
3767 close(t.z[2].x, 0.30);
3768 close(t.windings[0].rate_a, 100.0);
3769 close(t.windings[1].tap, 1.025);
3770 close(t.windings[2].shift, 30.0);
3771 close(t.star_vm, 0.98);
3772 close(t.star_va, -1.5);
3773
3774 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3776 assert_eq!(net2.transformers_3w.len(), 1);
3777 assert!(net2.branches.is_empty());
3778 let t2 = &net2.transformers_3w[0];
3779 close(t2.z[1].x, 0.20);
3780 close(t2.windings[2].tap, 0.95);
3781 close(t2.star_va, -1.5);
3782 assert_eq!(t2.name.as_deref(), Some("T3W"));
3783 }
3784
3785 #[test]
3786 fn three_winding_cross_format_warns_and_survives_normalization() {
3787 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3790CASE
3791COMMENT
37921,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37932,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37943,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37950 / END OF BUS DATA, BEGIN LOAD DATA
37960 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37970 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37981,'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
37990 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38000 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38011, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
38020.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
38031.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
38041.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38050.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38060 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3807Q
3808";
3809 let net = parse_psse(raw).unwrap();
3810 assert_eq!(net.transformers_3w.len(), 1);
3811
3812 let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3815 assert!(
3816 mpc.warnings.iter().any(|w| w.contains("3-winding")),
3817 "MATPOWER write must warn on the dropped 3-winding transformer, got {:?}",
3818 mpc.warnings
3819 );
3820
3821 let norm = net.to_normalized().unwrap();
3823 assert_eq!(norm.transformers_3w.len(), 1, "to_normalized keeps the 3W");
3824 norm.validate().unwrap();
3825 }
3826
3827 #[test]
3828 fn writing_a_different_revision_re_emits_instead_of_echoing() {
3829 let raw = "0, 100.00, 33, 0, 0, 60.00 / x
3832CASE
3833COMMENT
38341,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
38350 / END OF BUS DATA, BEGIN LOAD DATA
38360 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38370 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38380 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38390 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38400 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3841Q
3842";
3843 let parsed = crate::parse_str(raw, "psse").unwrap();
3844 let same = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 33 }).unwrap();
3845 assert_eq!(same.text, raw, "same revision echoes the retained source");
3846 let v34 = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 34 }).unwrap();
3847 assert_ne!(v34.text, raw, "a different revision must re-emit, not echo");
3848 assert!(
3849 v34.text.contains("END OF SYSTEM-WIDE DATA"),
3850 "v34 output carries the system-wide marker, got:\n{}",
3851 v34.text
3852 );
3853 }
3854
3855 #[test]
3856 fn warns_on_a_nonempty_unmodeled_section() {
3857 let raw = "0, 100.00, 34, 0, 0, 60.00 / x
3860CASE
3861COMMENT
38621,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
38630 / END OF BUS DATA, BEGIN LOAD DATA
38640 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38650 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38660 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38670 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38680 / END OF TRANSFORMER DATA, BEGIN AREA DATA
38690 / END OF AREA DATA, BEGIN SUBSTATION DATA
38701, 'SUB1', 21.3, -157.8, 0.001
38710 / END OF SUBSTATION DATA, BEGIN GNE DEVICE DATA
3872Q
3873";
3874 let parsed = crate::parse_str(raw, "psse").unwrap();
3875 assert!(
3876 parsed
3877 .warnings
3878 .iter()
3879 .any(|w| w.contains("SUBSTATION") && w.contains("not modeled")),
3880 "an unmodeled substation section must be reported, got {:?}",
3881 parsed.warnings
3882 );
3883 }
3884
3885 #[test]
3886 fn reads_writes_and_drops_an_emergency_voltage_band() {
3887 let raw = r"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.2,0.8
38932,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
38940 / END OF BUS DATA, BEGIN LOAD DATA
38950 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38960 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38971,'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
38980 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38990 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39000 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3901Q
3902";
3903 let net = parse_psse(raw).unwrap();
3904 let b1 = net.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3905 assert!(
3906 b1.evhi.is_some() && b1.evlo.is_some(),
3907 "distinct band typed"
3908 );
3909 close(b1.evhi.unwrap(), 1.2);
3910 close(b1.evlo.unwrap(), 0.8);
3911 let b2 = net.buses.iter().find(|b| b.id == BusId(2)).unwrap();
3912 assert!(
3913 b2.evhi.is_none() && b2.evlo.is_none(),
3914 "an emergency band equal to the normal band stays None"
3915 );
3916
3917 let net2 = parse_psse(&write_psse(&net).text).unwrap();
3919 let r1 = net2.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3920 close(r1.evhi.unwrap(), 1.2);
3921 close(r1.evlo.unwrap(), 0.8);
3922
3923 let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3925 assert!(
3926 mpc.warnings
3927 .iter()
3928 .any(|w| w.contains("emergency voltage band")),
3929 "MATPOWER write must warn on the dropped emergency band, got {:?}",
3930 mpc.warnings
3931 );
3932 }
3933
3934 #[test]
3935 fn writes_v34_v35_layouts_that_round_trip() {
3936 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3937CASE
3938COMMENT
39391,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39402,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39410 / END OF BUS DATA, BEGIN LOAD DATA
39422,'1',1,1,1,10.0,5.0,0,0,0,0,1,1,0
39430 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39440 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39450 / END OF GENERATOR DATA, BEGIN BRANCH DATA
39461,2,'1 ',0.01,0.05,0.001,111.0,90.0,80.0,0,0,0,0,1,1,0,1,1
39470 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39480 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3949Q
3950";
3951 let net = parse_psse(raw).unwrap();
3952
3953 for rev in [34u32, 35] {
3954 let text = write_psse_rev(&net, rev).text;
3955 assert!(
3957 text.contains("END OF SYSTEM-WIDE DATA, BEGIN BUS DATA"),
3958 "rev {rev} missing the system-wide marker"
3959 );
3960 let header = text.lines().next().unwrap();
3961 assert!(header.contains(&format!(", {rev}, ")), "header {header:?}");
3962 let branch = text.lines().find(|l| l.starts_with("1, 2, '1'")).unwrap();
3964 assert!(
3965 branch.split(',').count() >= 24,
3966 "rev {rev} branch is not the named layout: {branch:?}"
3967 );
3968
3969 let back = parse_psse(&text).unwrap();
3970 assert_eq!(back.buses.len(), 2);
3971 assert_eq!(back.loads.len(), 1);
3972 assert_eq!(back.branches.len(), 1);
3973 close(back.branches[0].rate_a, 111.0);
3974 close(back.loads[0].p, 10.0);
3975 assert!(back.branches[0].in_service);
3976 }
3977
3978 assert!(
3980 write_psse_rev(&net, 35).text.contains(", ''"),
3981 "v35 load should carry a LOADTYPE field"
3982 );
3983 }
3984
3985 #[test]
3986 fn writer_sanitizes_bus_names_that_would_corrupt_a_record() {
3987 let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3992CASE
3993COMMENT
39941,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
39952,'BUS2 ', 138.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
39960 / END OF BUS DATA, BEGIN LOAD DATA
39970 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39980 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39990 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40000 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40010 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4002Q
4003";
4004 let mut net = parse_psse(raw).unwrap();
4005 net.buses[0].name = Some("O'Brien/X".to_string());
4006
4007 let conv = write_psse(&net);
4008 let reparsed = parse_psse(&conv.text).unwrap();
4009
4010 assert_eq!(reparsed.buses.len(), 2);
4011 close(reparsed.buses[0].base_kv, 230.0);
4012 close(reparsed.buses[1].base_kv, 138.0);
4013 let name = reparsed.buses[0].name.as_deref().unwrap();
4014 assert!(!name.contains('\'') && !name.contains('/'), "got {name:?}");
4015 assert!(
4016 conv.warnings
4017 .iter()
4018 .any(|w| w.contains("quoted PSS/E field")),
4019 "expected a sanitization warning, got {:?}",
4020 conv.warnings
4021 );
4022 }
4023
4024 #[test]
4025 fn malformed_first_bus_id_is_not_treated_as_system_wide_data() {
4026 let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic malformed export
4027CASE
4028COMMENT
4029BAD,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
40300 / END OF BUS DATA, BEGIN LOAD DATA
4031Q
4032";
4033
4034 let err = parse_psse(raw).unwrap_err();
4035
4036 assert!(
4037 err.to_string().contains("bus record missing numeric id"),
4038 "malformed bus id should be reported directly: {err}"
4039 );
4040 }
4041}