1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
11use std::fmt::Write as _;
12use std::sync::Arc;
13
14use serde_json::{Number, Value};
15
16use super::{Conversion, sanitize_quoted, warn_extra_branch_rating_sets};
17use crate::network::{
18 BalancedNetwork, Branch, Bus, BusId, BusType, Extras, Generator, Hvdc, Impedance, Load,
19 LoadVoltageModel, Shunt, SourceFormat, Transformer3W, Winding,
20};
21use crate::{Error, Result};
22
23const FMT: &str = "PSLF .epc";
24
25const NAME_FORBIDDEN: &[char] = &['"'];
28
29pub fn parse_pslf(content: &str) -> Result<BalancedNetwork> {
35 let mut warnings = Vec::new();
36 parse_pslf_source(Arc::new(content.to_owned()), None, &mut warnings)
37}
38
39pub(crate) fn parse_pslf_source(
41 source: Arc<String>,
42 name_hint: Option<&str>,
43 warnings: &mut Vec<String>,
44) -> Result<BalancedNetwork> {
45 let doc = parse_document(&source, warnings);
46 let base_mva = doc.base_mva(warnings);
47 let name = doc.name(name_hint);
48 let mut once = HashSet::new();
49
50 let mut buses = Vec::new();
51 let mut bus_voltage = HashMap::new();
52 for rec in doc.records("bus data") {
53 let bus = read_bus(rec)?;
54 bus_voltage.insert(bus.id, (bus.vm, bus.base_kv));
55 buses.push(bus);
56 }
57
58 let mut loads = Vec::new();
59 for rec in doc.records("load data") {
60 loads.push(read_load(rec, warnings, &mut once)?);
61 }
62
63 let mut shunts = Vec::new();
64 for rec in doc.records("shunt data") {
65 shunts.push(read_shunt(rec, base_mva)?);
66 }
67 for rec in doc.records("svd data") {
68 shunts.push(read_svd(rec, base_mva, warnings, &mut once)?);
69 }
70
71 let jump = doc.jump_threshold();
72 let mut near_jump = 0usize;
73 let mut branches = Vec::new();
74 for rec in doc.records("branch data") {
75 let branch = read_branch(rec)?;
76 if let Some(threshold) = jump {
77 if branch.x.abs() <= threshold {
78 near_jump += 1;
79 }
80 }
81 branches.push(branch);
82 }
83 if near_jump > 0 {
84 warnings.push(format!(
85 "{near_jump} branch(es) have |x| at or below the PSLF jump threshold"
86 ));
87 }
88
89 let mut transformers_3w = Vec::new();
90 for rec in doc.records("transformer data") {
91 match read_transformer(rec)? {
92 TransformerRecord::TwoWinding(branch) => branches.push(branch),
93 TransformerRecord::ThreeWinding(t) => transformers_3w.push(t),
94 }
95 }
96 if !transformers_3w.is_empty() {
97 warnings.push(
98 "PSLF 3-winding transformer(s) mapped with the primary winding ratio/ratings; \
99 secondary/tertiary winding ratios default to nominal"
100 .into(),
101 );
102 }
103
104 let mut generators = Vec::new();
105 for rec in doc.records("generator data") {
106 generators.push(read_generator(rec, &bus_voltage, warnings)?);
107 }
108
109 let dc_converters = read_dc_converters(&doc, warnings);
110 let hvdc = read_dc_lines(&doc, &dc_converters, warnings);
111
112 warn_unmodeled_sections(&doc, warnings);
113
114 let net = BalancedNetwork {
115 name,
116 base_mva,
117 base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
118 geo: None,
119 buses,
120 loads,
121 shunts,
122 branches,
123 switches: Vec::new(),
124 generators,
125 storage: Vec::new(),
126 hvdc,
127 transformers_3w,
128 areas: Vec::new(),
129 solver: None,
130 source_format: SourceFormat::Pslf,
131 source: Some(source),
132 };
133 net.check_references(FMT)?;
134 Ok(net)
135}
136
137#[derive(Debug)]
144struct EpcDocument {
145 title: Vec<String>,
146 solution_parameters: Vec<String>,
147 sections: BTreeMap<String, Section>,
148}
149
150impl EpcDocument {
151 fn name(&self, name_hint: Option<&str>) -> String {
153 self.title
154 .iter()
155 .map(String::as_str)
156 .map(str::trim)
157 .find(|line| !line.is_empty())
158 .map_or_else(|| name_hint.unwrap_or("case").to_string(), str::to_string)
159 }
160
161 fn records(&self, section: &str) -> &[Record] {
163 self.sections
164 .get(section)
165 .map_or(&[], |section| section.records.as_slice())
166 }
167
168 fn base_mva(&self, warnings: &mut Vec<String>) -> f64 {
170 for line in &self.solution_parameters {
171 let toks = tokens(line);
172 if toks
173 .first()
174 .is_some_and(|tok| tok.eq_ignore_ascii_case("sbase"))
175 {
176 if let Some(base) = toks.get(1).and_then(|tok| tok.parse::<f64>().ok()) {
177 return base;
178 }
179 }
180 }
181 warnings.push("no PSLF sbase solution parameter found; defaulting baseMVA to 100".into());
182 100.0
183 }
184
185 fn jump_threshold(&self) -> Option<f64> {
187 self.solution_parameters.iter().find_map(|line| {
188 let toks = tokens(line);
189 toks.first()
190 .filter(|tok| tok.eq_ignore_ascii_case("jump"))
191 .and_then(|_| toks.get(1))
192 .and_then(|tok| tok.parse().ok())
193 })
194 }
195}
196
197#[derive(Debug)]
202struct Section {
203 declared_count: usize,
204 header: String,
205 records: Vec<Record>,
206}
207
208#[derive(Debug)]
214struct Record {
215 line_no: usize,
216 raw: Vec<String>,
217 lhs: Vec<String>,
218 rhs: Vec<String>,
219}
220
221#[expect(clippy::too_many_lines)]
228fn parse_document(content: &str, warnings: &mut Vec<String>) -> EpcDocument {
229 let lines: Vec<&str> = content.lines().collect();
230 let mut i = 0usize;
231 let mut title = Vec::new();
232 let mut solution_parameters = Vec::new();
233 let mut sections = BTreeMap::new();
234 let mut end_seen = false;
235
236 while i < lines.len() {
237 let raw = lines[i].trim_end_matches('\r');
238 let stripped = raw.trim();
239 if stripped.is_empty() || stripped.starts_with('#') {
240 i += 1;
241 continue;
242 }
243 if stripped.eq_ignore_ascii_case("end") {
244 end_seen = true;
245 break;
246 }
247
248 let lower = stripped.to_ascii_lowercase();
249 if matches!(lower.as_str(), "title" | "comments" | "solution parameters") {
250 i += 1;
251 let mut block = Vec::new();
252 while i < lines.len() && lines[i].trim() != "!" {
253 block.push(lines[i].trim_end_matches('\r').to_string());
254 i += 1;
255 }
256 if i < lines.len() && lines[i].trim() == "!" {
257 i += 1;
258 }
259 match lower.as_str() {
260 "title" => title = block,
261 "solution parameters" => solution_parameters = block,
262 _ => {}
263 }
264 continue;
265 }
266
267 let Some((name, count, header)) = parse_section_header(stripped) else {
268 warnings.push(format!(
269 "line {} ignored outside a PSLF data section",
270 i + 1
271 ));
272 i += 1;
273 continue;
274 };
275 i += 1;
276
277 let mut records = Vec::new();
278 while records.len() < count && i < lines.len() {
279 if lines[i].trim().is_empty() {
280 i += 1;
281 continue;
282 }
283 let next = lines[i].trim();
284 if parse_section_header(next).is_some() || next.eq_ignore_ascii_case("end") {
285 break;
286 }
287
288 let line_no = i + 1;
289 let mut raw_lines = Vec::new();
290 loop {
291 let (line, continued) = clean_line(lines[i]);
292 if !line.trim().is_empty() {
293 raw_lines.push(line);
294 }
295 i += 1;
296 if !continued || i >= lines.len() {
297 break;
298 }
299 }
300 let (lhs, rhs) = split_record(&raw_lines);
301 records.push(Record {
302 line_no,
303 raw: raw_lines,
304 lhs,
305 rhs,
306 });
307 }
308
309 if records.len() != count {
310 warnings.push(format!(
311 "{}: declared {count}, parsed {}",
312 name,
313 records.len()
314 ));
315 }
316 if sections
317 .insert(
318 name.clone(),
319 Section {
320 declared_count: count,
321 header,
322 records,
323 },
324 )
325 .is_some()
326 {
327 warnings.push(format!(
328 "{name}: duplicate section replaced earlier records"
329 ));
330 }
331 }
332
333 if !end_seen {
334 warnings.push("PSLF file has no end marker".into());
335 }
336
337 EpcDocument {
338 title,
339 solution_parameters,
340 sections,
341 }
342}
343
344fn parse_section_header(line: &str) -> Option<(String, usize, String)> {
349 let lower = line.to_ascii_lowercase();
350 let data_at = lower.find(" data")?;
351 let open = line[data_at + 5..].find('[')? + data_at + 5;
352 let close = line[open + 1..].find(']')? + open + 1;
353 let name = line[..data_at + 5].trim().to_ascii_lowercase();
354 let count = line[open + 1..close].trim().parse().ok()?;
355 let header = line[close + 1..].trim_end().to_string();
356 Some((name, count, header))
357}
358
359fn clean_line(raw: &str) -> (String, bool) {
364 let raw = raw.trim_end_matches('\r');
365 let trimmed = raw.trim_end();
366 let continued = ends_with_unquoted_slash(trimmed);
367 if continued {
368 let without = &trimmed[..trimmed.len() - 1];
369 (without.trim_end().to_string(), true)
370 } else {
371 (raw.to_string(), false)
372 }
373}
374
375fn ends_with_unquoted_slash(line: &str) -> bool {
376 if !line.ends_with('/') {
377 return false;
378 }
379 let before = &line[..line.len() - 1];
380 let mut quoted = false;
381 let mut chars = before.chars().peekable();
382 while let Some(ch) = chars.next() {
383 if ch == '"' {
384 if quoted && chars.peek() == Some(&'"') {
385 chars.next();
386 } else {
387 quoted = !quoted;
388 }
389 }
390 }
391 !quoted
392}
393
394fn split_record(raw_lines: &[String]) -> (Vec<String>, Vec<String>) {
396 let toks = tokens(&raw_lines.join(" "));
397 split_tokens(toks)
398}
399
400fn split_tokens(toks: Vec<String>) -> (Vec<String>, Vec<String>) {
402 if let Some(colon) = toks.iter().position(|tok| tok == ":") {
403 (toks[..colon].to_vec(), toks[colon + 1..].to_vec())
404 } else {
405 (toks, Vec::new())
406 }
407}
408
409fn tokens(line: &str) -> Vec<String> {
413 let mut out = Vec::new();
414 let mut cur = String::new();
415 let mut quoted = false;
416 let mut chars = line.chars().peekable();
417 while let Some(ch) = chars.next() {
418 match ch {
419 '"' => {
420 if quoted && chars.peek() == Some(&'"') {
421 cur.push('"');
422 chars.next();
423 } else {
424 quoted = !quoted;
425 if !quoted {
426 out.push(std::mem::take(&mut cur));
427 }
428 }
429 }
430 ':' if !quoted => {
431 if !cur.is_empty() {
432 out.push(std::mem::take(&mut cur));
433 }
434 out.push(":".into());
435 }
436 c if c.is_whitespace() && !quoted => {
437 if !cur.is_empty() {
438 out.push(std::mem::take(&mut cur));
439 }
440 }
441 c => cur.push(c),
442 }
443 }
444 if !cur.is_empty() {
445 out.push(cur);
446 }
447 out
448}
449
450fn line_rhs(rec: &Record, line: usize) -> Vec<String> {
452 rec.raw
453 .get(line)
454 .map(|line| split_tokens(tokens(line)).1)
455 .unwrap_or_default()
456}
457
458fn line_tokens(rec: &Record, line: usize) -> Vec<String> {
460 rec.raw.get(line).map_or_else(Vec::new, |line| tokens(line))
461}
462
463fn read_bus(rec: &Record) -> Result<Bus> {
465 let id = BusId(req_id(&rec.lhs, 0, "bus id", rec)?);
466 let name = rec.lhs.get(1).map(|name| name.trim().to_string());
467 Ok(Bus {
468 id,
469 kind: pslf_bus_type(int_at(&rec.rhs, 0, 1, "bus type", rec)?),
470 vm: num_at(&rec.rhs, 2, 1.0, "bus voltage", rec)?,
471 va: num_at(&rec.rhs, 3, 0.0, "bus angle", rec)?,
472 base_kv: num_at(&rec.lhs, 2, 0.0, "bus nominal kV", rec)?,
473 vmax: num_at(&rec.rhs, 6, 1.1, "bus vmax", rec)?,
474 vmin: num_at(&rec.rhs, 7, 0.9, "bus vmin", rec)?,
475 evhi: None,
476 evlo: None,
477 area: id_at(&rec.rhs, 4, 1, "bus area", rec)?,
478 zone: id_at(&rec.rhs, 5, 1, "bus zone", rec)?,
479 name,
480 uid: None,
481 location: None,
482 extras: extras(rec, "bus data", 3, 21),
483 })
484}
485
486fn pslf_bus_type(code: i64) -> BusType {
488 match code {
489 0 => BusType::Ref,
490 2 => BusType::Pv,
491 4 => BusType::Isolated,
492 _ => BusType::Pq,
493 }
494}
495
496fn read_branch(rec: &Record) -> Result<Branch> {
498 let mut extras = extras(rec, "branch data", 9, 10);
499 if let Some(circuit) = rec.lhs.get(6) {
500 extras.insert("pslf_circuit".into(), Value::String(circuit.clone()));
501 }
502 if let Some(section) = rec.lhs.get(7) {
503 extras.insert("pslf_section_id".into(), string_or_number(section));
504 }
505 Ok(Branch {
506 from: BusId(req_id(&rec.lhs, 0, "branch from bus", rec)?),
507 to: BusId(req_id(&rec.lhs, 3, "branch to bus", rec)?),
508 r: num_at(&rec.rhs, 1, 0.0, "branch r", rec)?,
509 x: num_at(&rec.rhs, 2, 0.0, "branch x", rec)?,
510 b: num_at(&rec.rhs, 3, 0.0, "branch b", rec)?,
511 charging: None,
512 rate_a: num_at(&rec.rhs, 4, 0.0, "branch rate1", rec)?,
513 rate_b: num_at(&rec.rhs, 5, 0.0, "branch rate2", rec)?,
514 rate_c: num_at(&rec.rhs, 6, 0.0, "branch rate3", rec)?,
515 rating_sets: Vec::new(),
516 current_ratings: None,
517 tap: 0.0,
518 shift: 0.0,
519 in_service: on_at(&rec.rhs, 0, true, "branch status", rec)?,
520 angmin: -360.0,
521 angmax: 360.0,
522 control: None,
523 solution: None,
524 uid: None,
525 route: None,
526 extras,
527 })
528}
529
530#[allow(clippy::large_enum_variant)]
535enum TransformerRecord {
536 TwoWinding(Branch),
537 ThreeWinding(Transformer3W),
538}
539
540fn read_transformer(rec: &Record) -> Result<TransformerRecord> {
548 let rhs1 = line_rhs(rec, 0);
549 let line2 = line_tokens(rec, 1);
550 let tertiary = id_at(&rhs1, 9, 0, "transformer tertiary bus", rec)?;
551 let pt_r = num_at(&rhs1, 17, 0.0, "transformer pt_r", rec)?;
552 let pt_x = num_at(&rhs1, 18, 0.0, "transformer pt_x", rec)?;
553 let ts_r = num_at(&rhs1, 19, 0.0, "transformer ts_r", rec)?;
554 let ts_x = num_at(&rhs1, 20, 0.0, "transformer ts_x", rec)?;
555 let from = BusId(req_id(&rec.lhs, 0, "transformer from bus", rec)?);
556 let to = BusId(req_id(&rec.lhs, 3, "transformer to bus", rec)?);
557 let r = num_at(&rhs1, 15, 0.0, "transformer r", rec)?;
558 let x = num_at(&rhs1, 16, 0.0, "transformer x", rec)?;
559 let tbase = num_at(&rhs1, 14, 0.0, "transformer base", rec)?;
560 let tap = num_at(&line2, 16, 1.0, "transformer tap", rec)?;
561 let shift = num_at(&line2, 10, 0.0, "transformer shift", rec)?;
562 let rate_a = num_at(&line2, 6, 0.0, "transformer rate1", rec)?;
563 let rate_b = num_at(&line2, 7, 0.0, "transformer rate2", rec)?;
564 let rate_c = num_at(&line2, 8, 0.0, "transformer rate3", rec)?;
565 let in_service = on_at(&rhs1, 0, true, "transformer status", rec)?;
566 let circuit = rec.lhs.get(6).cloned();
567 let name = rec
568 .lhs
569 .get(8)
570 .filter(|n| !n.trim().is_empty())
571 .map(|n| n.trim().to_string());
572
573 if tertiary != 0 || pt_r != 0.0 || pt_x != 0.0 || ts_r != 0.0 || ts_x != 0.0 {
574 let mut extras = extras(rec, "transformer data", 8, 21);
575 if let Some(c) = circuit {
576 extras.insert("pslf_circuit".into(), Value::String(c));
577 }
578 let nominal = |bus| Winding {
579 bus,
580 tap: 1.0,
581 shift: 0.0,
582 nominal_kv: 0.0,
583 rate_a: 0.0,
584 rate_b: 0.0,
585 rate_c: 0.0,
586 };
587 let imp = |r, x| Impedance {
588 r,
589 x,
590 base_mva: tbase,
591 };
592 let t3 = Transformer3W {
593 windings: [
594 Winding {
595 bus: from,
596 tap: if tap == 0.0 { 1.0 } else { tap },
597 shift,
598 nominal_kv: 0.0,
599 rate_a,
600 rate_b,
601 rate_c,
602 },
603 nominal(to),
604 nominal(BusId(tertiary)),
605 ],
606 z: [imp(r, x), imp(ts_r, ts_x), imp(pt_r, pt_x)],
608 star_vm: 1.0,
609 star_va: 0.0,
610 mag_g: 0.0,
611 mag_b: 0.0,
612 in_service,
613 name,
614 uid: None,
615 extras,
616 };
617 return Ok(TransformerRecord::ThreeWinding(t3));
618 }
619
620 let mut extras = extras(rec, "transformer data", 8, 21);
621 if let Some(c) = circuit {
622 extras.insert("pslf_circuit".into(), Value::String(c));
623 }
624 extras.insert("pslf_tbase".into(), number_value(tbase));
625 Ok(TransformerRecord::TwoWinding(Branch {
626 from,
627 to,
628 r,
629 x,
630 b: 0.0,
631 charging: None,
632 rate_a,
633 rate_b,
634 rate_c,
635 rating_sets: Vec::new(),
636 current_ratings: None,
637 tap: if tap == 0.0 { 1.0 } else { tap },
638 shift,
639 in_service,
640 angmin: -360.0,
641 angmax: 360.0,
642 control: None,
643 solution: None,
644 uid: None,
645 route: None,
646 extras,
647 }))
648}
649
650fn read_generator(
656 rec: &Record,
657 bus_voltage: &HashMap<BusId, (f64, f64)>,
658 warnings: &mut Vec<String>,
659) -> Result<Generator> {
660 let bus = BusId(req_id(&rec.lhs, 0, "generator bus", rec)?);
661 let (bus_vm, base_kv) = bus_voltage.get(&bus).copied().unwrap_or((1.0, 0.0));
662 let reg_kv = num_at(&rec.rhs, 3, 0.0, "generator reg_kv", rec)?;
663 let vg = if reg_kv > 0.0 && base_kv > 0.0 {
664 reg_kv / base_kv
665 } else {
666 if reg_kv > 0.0 {
667 warnings.push(format!(
668 "PSLF generator at bus {bus}: reg_kv present but bus base kV is missing; used bus voltage"
669 ));
670 }
671 bus_vm
672 };
673 Ok(Generator {
674 bus,
675 pg: num_at(&rec.rhs, 8, 0.0, "generator pgen", rec)?,
676 qg: num_at(&rec.rhs, 11, 0.0, "generator qgen", rec)?,
677 pmax: num_at(&rec.rhs, 9, 0.0, "generator pmax", rec)?,
678 pmin: num_at(&rec.rhs, 10, 0.0, "generator pmin", rec)?,
679 qmax: num_at(&rec.rhs, 12, 0.0, "generator qmax", rec)?,
680 qmin: num_at(&rec.rhs, 13, 0.0, "generator qmin", rec)?,
681 vg,
682 mbase: num_at(&rec.rhs, 14, 100.0, "generator mbase", rec)?,
683 in_service: on_at(&rec.rhs, 0, true, "generator status", rec)?,
684 cost: None,
685 caps: Default::default(),
686 regulated_bus: None,
687 uid: None,
688 })
689}
690
691fn read_load(
696 rec: &Record,
697 warnings: &mut Vec<String>,
698 once: &mut HashSet<&'static str>,
699) -> Result<Load> {
700 let p_const = num_at(&rec.rhs, 1, 0.0, "load mw", rec)?;
701 let q_const = num_at(&rec.rhs, 2, 0.0, "load mvar", rec)?;
702 let p_i = num_at(&rec.rhs, 3, 0.0, "load mw_i", rec)?;
703 let q_i = num_at(&rec.rhs, 4, 0.0, "load mvar_i", rec)?;
704 let p_z = num_at(&rec.rhs, 5, 0.0, "load mw_z", rec)?;
705 let q_z = num_at(&rec.rhs, 6, 0.0, "load mvar_z", rec)?;
706 let has_zip_components = (p_i, q_i, p_z, q_z) != (0.0, 0.0, 0.0, 0.0);
707 if has_zip_components && once.insert("zip_load") {
708 warnings.push(
711 "PSLF ZIP load components folded into BalancedNetwork load p/q; component fields retained in the typed load voltage model"
712 .into(),
713 );
714 }
715 let mut extras = extras(rec, "load data", 5, 20);
716 capture_device_id(&mut extras, &rec.lhs);
717 extras.insert("pslf_mw".into(), number_value(p_const));
718 extras.insert("pslf_mvar".into(), number_value(q_const));
719 extras.insert("pslf_mw_i".into(), number_value(p_i));
720 extras.insert("pslf_mvar_i".into(), number_value(q_i));
721 extras.insert("pslf_mw_z".into(), number_value(p_z));
722 extras.insert("pslf_mvar_z".into(), number_value(q_z));
723 Ok(Load {
724 bus: BusId(req_id(&rec.lhs, 0, "load bus", rec)?),
725 p: p_const + p_i + p_z,
726 q: q_const + q_i + q_z,
727 voltage_model: has_zip_components.then_some(LoadVoltageModel::Zip {
728 p_constant_power: p_const,
729 q_constant_power: q_const,
730 p_constant_current: p_i,
731 q_constant_current: q_i,
732 p_constant_impedance: p_z,
733 q_constant_impedance: q_z,
734 v_nom: None,
735 load_type: None,
736 scaling: None,
737 }),
738 in_service: on_at(&rec.rhs, 0, true, "load status", rec)?,
739 uid: None,
740 extras,
741 })
742}
743
744fn read_shunt(rec: &Record, base_mva: f64) -> Result<Shunt> {
746 let g_pu = num_at(&rec.rhs, 3, 0.0, "shunt pu_mw", rec)?;
747 let b_pu = num_at(&rec.rhs, 4, 0.0, "shunt pu_mvar", rec)?;
748 let mut extras = extras(rec, "shunt data", 10, 29);
749 capture_device_id(&mut extras, &rec.lhs);
750 extras.insert("pslf_pu_mw".into(), number_value(g_pu));
751 extras.insert("pslf_pu_mvar".into(), number_value(b_pu));
752 Ok(Shunt {
753 bus: BusId(req_id(&rec.lhs, 0, "shunt bus", rec)?),
754 g: g_pu * base_mva,
755 b: b_pu * base_mva,
756 in_service: on_at(&rec.rhs, 0, true, "shunt status", rec)?,
757 control: None,
758 uid: None,
759 extras,
760 })
761}
762
763fn read_svd(
768 rec: &Record,
769 base_mva: f64,
770 warnings: &mut Vec<String>,
771 once: &mut HashSet<&'static str>,
772) -> Result<Shunt> {
773 if once.insert("svd") {
774 warnings.push(
775 "PSLF controlled shunts (svd data) reduced to fixed shunts at initial g/b; control fields retained in extras"
776 .into(),
777 );
778 }
779 let g_pu = num_at(&rec.rhs, 7, 0.0, "svd g", rec)?;
780 let b_pu = num_at(&rec.rhs, 8, 0.0, "svd b", rec)?;
781 let mut extras = extras(rec, "svd data", 5, 30);
782 capture_device_id(&mut extras, &rec.lhs);
783 extras.insert("pslf_device".into(), Value::String("svd".into()));
784 extras.insert("pslf_pu_g".into(), number_value(g_pu));
785 extras.insert("pslf_pu_b".into(), number_value(b_pu));
786 Ok(Shunt {
787 bus: BusId(req_id(&rec.lhs, 0, "svd bus", rec)?),
788 g: g_pu * base_mva,
789 b: b_pu * base_mva,
790 in_service: on_at(&rec.rhs, 0, true, "svd status", rec)?,
791 control: None,
792 uid: None,
793 extras,
794 })
795}
796
797#[derive(Clone)]
802struct DcConverter {
803 ac_bus: BusId,
804 dc_bus: usize,
805 in_service: bool,
806 p: f64,
807 q: f64,
808 extras: Extras,
809}
810
811fn read_dc_converters(
816 doc: &EpcDocument,
817 warnings: &mut Vec<String>,
818) -> HashMap<usize, DcConverter> {
819 let mut out = HashMap::new();
820 for rec in doc.records("dc converter data") {
821 let parsed = (|| -> Result<DcConverter> {
822 let l2 = line_tokens(rec, 1);
823 let mut extras = extras(rec, "dc converter data", 8, 15);
824 extras.insert("pslf_device".into(), Value::String("dc_converter".into()));
825 Ok(DcConverter {
826 ac_bus: BusId(req_id(&rec.lhs, 0, "dc converter AC bus", rec)?),
827 dc_bus: req_id(&rec.lhs, 3, "dc converter DC bus", rec)?,
828 in_service: on_at(&rec.rhs, 0, true, "dc converter status", rec)?,
829 p: num_at(&l2, 2, 0.0, "dc converter p", rec)?,
830 q: num_at(&l2, 3, 0.0, "dc converter q", rec)?,
831 extras,
832 })
833 })();
834 match parsed {
835 Ok(conv) => {
836 out.insert(conv.dc_bus, conv);
837 }
838 Err(err) => warnings.push(format!(
839 "dc converter at line {} not mapped: {err}",
840 rec.line_no
841 )),
842 }
843 }
844 out
845}
846
847fn read_dc_lines(
853 doc: &EpcDocument,
854 converters: &HashMap<usize, DcConverter>,
855 warnings: &mut Vec<String>,
856) -> Vec<Hvdc> {
857 let mut out = Vec::new();
858 for rec in doc.records("dc line data") {
859 let parsed = (|| -> Result<Hvdc> {
860 let from_dc = req_id(&rec.lhs, 0, "dc line from bus", rec)?;
861 let to_dc = req_id(&rec.lhs, 3, "dc line to bus", rec)?;
862 let from = converters.get(&from_dc).ok_or_else(|| Error::FormatRead {
863 format: FMT,
864 message: format!("dc line references DC bus {from_dc} with no converter"),
865 })?;
866 let to = converters.get(&to_dc).ok_or_else(|| Error::FormatRead {
867 format: FMT,
868 message: format!("dc line references DC bus {to_dc} with no converter"),
869 })?;
870 let rate = num_at(&rec.rhs, 6, 0.0, "dc line rate1", rec)?;
871 let pmax = if rate > 0.0 {
872 rate
873 } else {
874 from.p.abs().max(to.p.abs())
875 };
876 let mut extras = extras(rec, "dc line data", 8, 20);
877 extras.insert("pslf_device".into(), Value::String("dc_line".into()));
878 extras.insert(
879 "pslf_from_converter".into(),
880 Value::Object(from.extras.clone().into_iter().collect()),
881 );
882 extras.insert(
883 "pslf_to_converter".into(),
884 Value::Object(to.extras.clone().into_iter().collect()),
885 );
886 Ok(Hvdc {
887 from: from.ac_bus,
888 to: to.ac_bus,
889 in_service: on_at(&rec.rhs, 0, true, "dc line status", rec)?
890 && from.in_service
891 && to.in_service,
892 pf: from.p,
893 pt: to.p,
894 qf: from.q,
895 qt: to.q,
896 vf: 1.0,
897 vt: 1.0,
898 pmin: -pmax,
899 pmax,
900 qminf: from.q.min(0.0),
901 qmaxf: from.q.max(0.0),
902 qmint: to.q.min(0.0),
903 qmaxt: to.q.max(0.0),
904 loss0: 0.0,
905 loss1: 0.0,
906 cost: None,
907 uid: None,
908 extras,
909 })
910 })();
911 match parsed {
912 Ok(line) => {
913 warnings.push(
914 "PSLF DC line/converter data mapped to BalancedNetwork HVDC with unsupported control fields retained in extras"
915 .into(),
916 );
917 out.push(line);
918 }
919 Err(err) => warnings.push(format!("dc line at line {} not mapped: {err}", rec.line_no)),
920 }
921 }
922 out
923}
924
925fn warn_unmodeled_sections(doc: &EpcDocument, warnings: &mut Vec<String>) {
927 const MODELED: &[&str] = &[
928 "bus data",
929 "branch data",
930 "transformer data",
931 "generator data",
932 "load data",
933 "shunt data",
934 "svd data",
935 "dc line data",
936 "dc converter data",
937 ];
938 for (name, section) in &doc.sections {
939 if section.declared_count > 0 && !MODELED.contains(&name.as_str()) {
940 warnings.push(format!(
941 "{name}: {} record(s) retained in source text only ({})",
942 section.declared_count, section.header
943 ));
944 }
945 }
946}
947
948fn extras(rec: &Record, section: &str, used_lhs: usize, used_rhs: usize) -> Extras {
954 let mut extras = Extras::new();
955 extras.insert("pslf_section".into(), Value::String(section.into()));
956 extras.insert("pslf_line".into(), number_value(rec.line_no as f64));
957 extras.insert("pslf_raw".into(), string_array(rec.raw.iter().cloned()));
958 if rec.lhs.len() > used_lhs {
959 extras.insert(
960 "pslf_lhs_extra".into(),
961 string_array(rec.lhs[used_lhs..].iter().cloned()),
962 );
963 }
964 if rec.rhs.len() > used_rhs {
965 extras.insert(
966 "pslf_rhs_extra".into(),
967 string_array(rec.rhs[used_rhs..].iter().cloned()),
968 );
969 }
970 extras
971}
972
973fn capture_device_id(extras: &mut Extras, lhs: &[String]) {
977 if let Some(id) = lhs.get(3).map(|s| s.trim()).filter(|s| !s.is_empty()) {
978 extras.insert("id".into(), Value::String(id.to_string()));
979 }
980}
981
982fn string_array(values: impl IntoIterator<Item = String>) -> Value {
984 Value::Array(values.into_iter().map(Value::String).collect())
985}
986
987fn string_or_number(token: &str) -> Value {
989 token
990 .parse::<f64>()
991 .ok()
992 .map_or_else(|| Value::String(token.to_string()), number_value)
993}
994
995fn number_value(value: f64) -> Value {
997 Number::from_f64(value).map_or(Value::Null, Value::Number)
998}
999
1000fn num_at(tokens: &[String], i: usize, default: f64, field: &str, rec: &Record) -> Result<f64> {
1002 match tokens.get(i).map(String::as_str) {
1003 None | Some("") => Ok(default),
1004 Some(tok) => tok.parse().map_err(|_| bad_field(field, i, tok, rec)),
1005 }
1006}
1007
1008fn int_at(tokens: &[String], i: usize, default: i64, field: &str, rec: &Record) -> Result<i64> {
1010 match tokens.get(i).map(String::as_str) {
1011 None | Some("") => Ok(default),
1012 Some(tok) => tok.parse().map_err(|_| bad_field(field, i, tok, rec)),
1013 }
1014}
1015
1016fn id_at(tokens: &[String], i: usize, default: usize, field: &str, rec: &Record) -> Result<usize> {
1018 match tokens.get(i).map(String::as_str) {
1019 None | Some("") => Ok(default),
1020 Some(tok) => parse_id(tok).ok_or_else(|| bad_field(field, i, tok, rec)),
1021 }
1022}
1023
1024fn req_id(tokens: &[String], i: usize, field: &str, rec: &Record) -> Result<usize> {
1026 tokens
1027 .get(i)
1028 .and_then(|tok| parse_id(tok))
1029 .ok_or_else(|| Error::FormatRead {
1030 format: FMT,
1031 message: format!("{field} missing or invalid at line {}", rec.line_no),
1032 })
1033}
1034
1035fn parse_id(tok: &str) -> Option<usize> {
1037 if let Ok(value) = tok.parse::<usize>() {
1038 return Some(value);
1039 }
1040 let value = tok.parse::<f64>().ok()?;
1041 if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > usize::MAX as f64 {
1042 return None;
1043 }
1044 Some(value as usize)
1045}
1046
1047fn on_at(tokens: &[String], i: usize, default: bool, field: &str, rec: &Record) -> Result<bool> {
1049 Ok(num_at(tokens, i, if default { 1.0 } else { 0.0 }, field, rec)? != 0.0)
1050}
1051
1052fn bad_field(field: &str, i: usize, tok: &str, rec: &Record) -> Error {
1054 Error::FormatRead {
1055 format: FMT,
1056 message: format!(
1057 "{field} field {i} value {tok:?} is invalid at line {}",
1058 rec.line_no
1059 ),
1060 }
1061}
1062
1063#[derive(Clone, Copy)]
1067struct BusRef<'a> {
1068 name: &'a str,
1069 base_kv: f64,
1070 area: usize,
1071 zone: usize,
1072}
1073
1074#[must_use]
1085#[expect(clippy::too_many_lines)]
1088pub fn write_pslf(net: &BalancedNetwork) -> Conversion {
1089 let mut warnings = Vec::new();
1090 let mut nonfinite = false;
1091 let mut sanitized_names = 0usize;
1092 let mut sanitized_ids = 0usize;
1093 let mut s = String::new();
1094
1095 let mut num = |x: f64| -> String {
1096 if x.is_finite() {
1097 format!("{x}")
1098 } else {
1099 nonfinite = true;
1100 let sentinel = if x > 0.0 {
1101 1.0e10
1102 } else if x < 0.0 {
1103 -1.0e10
1104 } else {
1105 0.0
1106 };
1107 format!("{sentinel}")
1108 }
1109 };
1110
1111 let bus_refs: HashMap<BusId, BusRef> = net
1113 .buses
1114 .iter()
1115 .map(|b| {
1116 (
1117 b.id,
1118 BusRef {
1119 name: b.name.as_deref().unwrap_or(""),
1120 base_kv: b.base_kv,
1121 area: b.area,
1122 zone: b.zone,
1123 },
1124 )
1125 })
1126 .collect();
1127 let bus_ref = |id: BusId| -> BusRef {
1128 bus_refs.get(&id).copied().unwrap_or(BusRef {
1129 name: "",
1130 base_kv: 0.0,
1131 area: 1,
1132 zone: 1,
1133 })
1134 };
1135 let mut name_tok = |name: &str| -> String {
1137 let clean = sanitize_quoted(name, NAME_FORBIDDEN, ' ');
1138 if matches!(clean, std::borrow::Cow::Owned(_)) {
1139 sanitized_names += 1;
1140 }
1141 format!("\"{clean}\"")
1142 };
1143
1144 let _ = writeln!(s, "title");
1146 let _ = writeln!(s, "{}", sanitize_quoted(&net.name, NAME_FORBIDDEN, ' '));
1149 let _ = writeln!(s, "!");
1150 let _ = writeln!(s, "comments");
1151 let _ = writeln!(s, "powerio export");
1152 let _ = writeln!(s, "!");
1153 let _ = writeln!(s, "solution parameters");
1154 let _ = writeln!(s, "sbase {}", num(net.base_mva));
1155 let _ = writeln!(s, "!");
1156
1157 let _ = writeln!(
1159 s,
1160 "bus data [{}] ty vsched volt angle ar zone vmax vmin",
1161 net.buses.len()
1162 );
1163 for b in &net.buses {
1164 let _ = writeln!(
1165 s,
1166 "{} {} {} : {} {} {} {} {} {} {} {}",
1167 b.id,
1168 name_tok(b.name.as_deref().unwrap_or("")),
1169 num(b.base_kv),
1170 pslf_type(b.kind),
1171 num(b.vm),
1172 num(b.vm),
1173 num(b.va),
1174 b.area,
1175 b.zone,
1176 num(b.vmax),
1177 num(b.vmin),
1178 );
1179 }
1180
1181 if !net.loads.is_empty() {
1183 let _ = writeln!(
1184 s,
1185 "load data [{}] id long_id st mw mvar mw_i mvar_i mw_z mvar_z ar zone",
1186 net.loads.len()
1187 );
1188 let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
1191 for l in &net.loads {
1192 let r = bus_ref(l.bus);
1193 let (mw, mvar, mw_i, mvar_i, mw_z, mvar_z) =
1194 load_components_for_write(l, &mut warnings);
1195 let id = device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_ids);
1196 let _ = writeln!(
1197 s,
1198 "{} {} {} \"{id}\" \"load\" : {} {} {} {} {} {} {} {} {}",
1199 l.bus,
1200 name_tok(r.name),
1201 num(r.base_kv),
1202 i32::from(l.in_service),
1203 num(mw),
1204 num(mvar),
1205 num(mw_i),
1206 num(mvar_i),
1207 num(mw_z),
1208 num(mvar_z),
1209 r.area,
1210 r.zone,
1211 );
1212 }
1213 }
1214
1215 if !net.shunts.is_empty() {
1217 let _ = writeln!(
1218 s,
1219 "shunt data [{}] id ck se long_id st ar zone pu_mw pu_mvar",
1220 net.shunts.len()
1221 );
1222 let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
1224 for sh in &net.shunts {
1225 let r = bus_ref(sh.bus);
1226 let pu_mw = extra_f64(&sh.extras, "pslf_pu_mw")
1229 .or_else(|| extra_f64(&sh.extras, "pslf_pu_g"))
1230 .unwrap_or_else(|| safe_div(sh.g, net.base_mva));
1231 let pu_mvar = extra_f64(&sh.extras, "pslf_pu_mvar")
1232 .or_else(|| extra_f64(&sh.extras, "pslf_pu_b"))
1233 .unwrap_or_else(|| safe_div(sh.b, net.base_mva));
1234 let id = device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_ids);
1235 let _ = writeln!(
1236 s,
1237 "{} {} {} \"{id}\" : {} {} {} {} {}",
1238 sh.bus,
1239 name_tok(r.name),
1240 num(r.base_kv),
1241 i32::from(sh.in_service),
1242 r.area,
1243 r.zone,
1244 num(pu_mw),
1245 num(pu_mvar),
1246 );
1247 }
1248 }
1249
1250 let lines: Vec<&Branch> = net
1252 .branches
1253 .iter()
1254 .filter(|b| !b.is_transformer())
1255 .collect();
1256 if !lines.is_empty() {
1257 let _ = writeln!(
1258 s,
1259 "branch data [{}] ck se long_id st resist react charge rate1 rate2 rate3",
1260 lines.len()
1261 );
1262 let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
1265 for br in lines {
1266 let f = bus_ref(br.from);
1267 let t = bus_ref(br.to);
1268 let ck = super::allocate_circuit_id(
1269 br.extras.get("pslf_circuit").and_then(Value::as_str),
1270 (br.from, br.to),
1271 &mut branch_ids,
1272 );
1273 let _ = writeln!(
1274 s,
1275 "{} {} {} {} {} {} \"{ck}\" 1 \"line\" : {} {} {} {} {} {} {}",
1276 br.from,
1277 name_tok(f.name),
1278 num(f.base_kv),
1279 br.to,
1280 name_tok(t.name),
1281 num(t.base_kv),
1282 i32::from(br.in_service),
1283 num(br.r),
1284 num(br.x),
1285 num(br.total_charging_b()),
1286 num(br.rate_a),
1287 num(br.rate_b),
1288 num(br.rate_c),
1289 );
1290 }
1291 }
1292
1293 let xfmrs: Vec<&Branch> = net.branches.iter().filter(|b| b.is_transformer()).collect();
1295 let n_xfmr = xfmrs.len() + net.transformers_3w.len();
1296 if n_xfmr > 0 {
1297 let _ = writeln!(s, "transformer data [{n_xfmr}]");
1298 for br in xfmrs {
1299 let f = bus_ref(br.from);
1300 let t = bus_ref(br.to);
1301 let tbase = extra_f64(&br.extras, "pslf_tbase").unwrap_or(net.base_mva);
1302 let mut rhs1 = vec!["0".to_string(); 21];
1307 rhs1[0] = i32::from(br.in_service).to_string();
1308 rhs1[14] = num(tbase);
1309 rhs1[15] = num(br.r);
1310 rhs1[16] = num(br.x);
1311 let _ = writeln!(
1312 s,
1313 "{} {} {} {} {} {} {} 1 \"xfmr\" : {} /",
1314 br.from,
1315 name_tok(f.name),
1316 num(f.base_kv),
1317 br.to,
1318 name_tok(t.name),
1319 num(t.base_kv),
1320 circuit_tok(&br.extras),
1321 rhs1.join(" "),
1322 );
1323 let mut line2 = vec!["0".to_string(); 17];
1325 line2[6] = num(br.rate_a);
1326 line2[7] = num(br.rate_b);
1327 line2[8] = num(br.rate_c);
1328 line2[10] = num(br.shift);
1329 line2[16] = num(br.effective_tap());
1330 let _ = writeln!(s, "{}", line2.join(" "));
1331 }
1332 for tr in &net.transformers_3w {
1333 let p = bus_ref(tr.windings[0].bus);
1334 let sec = bus_ref(tr.windings[1].bus);
1335 let [z12, z23, z31] = tr.z;
1336 let mut rhs1 = vec!["0".to_string(); 21];
1340 rhs1[0] = i32::from(tr.in_service).to_string();
1341 rhs1[9] = tr.windings[2].bus.to_string();
1342 rhs1[14] = num(z12.base_mva);
1343 rhs1[15] = num(z12.r);
1344 rhs1[16] = num(z12.x);
1345 rhs1[17] = num(z31.r);
1346 rhs1[18] = num(z31.x);
1347 rhs1[19] = num(z23.r);
1348 rhs1[20] = num(z23.x);
1349 let _ = writeln!(
1350 s,
1351 "{} {} {} {} {} {} {} 1 \"xf3\" : {} /",
1352 tr.windings[0].bus,
1353 name_tok(p.name),
1354 num(p.base_kv),
1355 tr.windings[1].bus,
1356 name_tok(sec.name),
1357 num(sec.base_kv),
1358 circuit_tok(&tr.extras),
1359 rhs1.join(" "),
1360 );
1361 let mut line2 = vec!["0".to_string(); 17];
1363 line2[6] = num(tr.windings[0].rate_a);
1364 line2[7] = num(tr.windings[0].rate_b);
1365 line2[8] = num(tr.windings[0].rate_c);
1366 line2[10] = num(tr.windings[0].shift);
1367 line2[16] = num(tr.windings[0].tap);
1368 let _ = writeln!(s, "{}", line2.join(" "));
1369 }
1370 }
1371
1372 if !net.generators.is_empty() {
1374 let _ = writeln!(
1375 s,
1376 "generator data [{}] id long_id st no reg_name reg_kv prf qrf ar zone \
1377 pgen pmax pmin qgen qmax qmin mbase",
1378 net.generators.len()
1379 );
1380 for g in &net.generators {
1381 let r = bus_ref(g.bus);
1382 let reg_kv = if g.vg.is_finite() && r.base_kv > 0.0 {
1386 g.vg * r.base_kv
1387 } else {
1388 if g.vg.is_finite() && (g.vg - 1.0).abs() > 1e-9 {
1389 warnings.push(format!(
1390 "PSLF generator at bus {}: voltage setpoint {} p.u. could not be written because bus base kV is missing",
1391 g.bus, g.vg
1392 ));
1393 }
1394 0.0
1395 };
1396 let _ = writeln!(
1397 s,
1398 "{} {} \"1\" \"gen\" : {} 1 0 {} 1 1 {} {} {} {} {} {} {} {} {}",
1399 g.bus,
1400 name_tok(r.name),
1401 i32::from(g.in_service),
1402 num(reg_kv),
1403 r.area,
1404 r.zone,
1405 num(g.pg),
1406 num(g.pmax),
1407 num(g.pmin),
1408 num(g.qg),
1409 num(g.qmax),
1410 num(g.qmin),
1411 num(g.mbase),
1412 );
1413 }
1414 }
1415
1416 if !net.hvdc.is_empty() {
1423 let _ = writeln!(
1424 s,
1425 "dc converter data [{}] id name kv dc_bus",
1426 net.hvdc.len() * 2
1427 );
1428 for (k, d) in net.hvdc.iter().enumerate() {
1429 for (ac, dc_bus, p, q) in [
1430 (d.from, 2 * k + 1, d.pf, d.qf),
1431 (d.to, 2 * k + 2, d.pt, d.qt),
1432 ] {
1433 let r = bus_ref(ac);
1434 let _ = writeln!(
1438 s,
1439 "{} {} {} {} : {} /",
1440 ac,
1441 name_tok(r.name),
1442 num(r.base_kv),
1443 dc_bus,
1444 i32::from(d.in_service),
1445 );
1446 let _ = writeln!(s, "0 0 {} {}", num(p), num(q));
1447 }
1448 }
1449 let _ = writeln!(
1450 s,
1451 "dc line data [{}] from name kv to st rate1",
1452 net.hvdc.len()
1453 );
1454 for (k, d) in net.hvdc.iter().enumerate() {
1455 let _ = writeln!(
1458 s,
1459 "{} \"dc\" 0 {} : {} 0 0 0 0 0 {}",
1460 2 * k + 1,
1461 2 * k + 2,
1462 i32::from(d.in_service),
1463 num(d.pmax),
1464 );
1465 }
1466 }
1467
1468 let _ = writeln!(s, "end");
1469
1470 let asymmetric_hvdc = net
1472 .hvdc
1473 .iter()
1474 .filter(|d| (d.pmin + d.pmax).abs() > 1e-9)
1475 .count();
1476 if asymmetric_hvdc > 0 {
1477 warnings.push(format!(
1478 "{asymmetric_hvdc} HVDC line(s) have asymmetric power limits (pmin != -pmax); \
1479 the PSLF .epc dc record carries only rate1 (= pmax), so pmin reads back as -pmax"
1480 ));
1481 }
1482 if !net.storage.is_empty() {
1483 warnings.push(format!(
1484 "{} storage unit(s) dropped: PSLF .epc has no storage record",
1485 net.storage.len()
1486 ));
1487 }
1488 if net.generators.iter().any(|g| g.cost.is_some()) {
1489 warnings.push("generator cost curves dropped: PSLF .epc carries no cost data".into());
1490 }
1491 let terminal_charging = net
1495 .branches
1496 .iter()
1497 .filter(|b| b.has_non_matpower_charging() && !b.is_transformer())
1498 .count();
1499 if terminal_charging > 0 {
1500 warnings.push(format!(
1501 "{terminal_charging} branch terminal admittance record(s) collapsed to total susceptance: PSLF branch records written here cannot carry conductance or asymmetric terminal charging"
1502 ));
1503 }
1504 let transformer_charging = net
1505 .branches
1506 .iter()
1507 .filter(|b| {
1508 b.is_transformer()
1509 && (b.terminal_charging().total_g().abs() > 1e-12
1510 || b.terminal_charging().total_b().abs() > 1e-12)
1511 })
1512 .count();
1513 if transformer_charging > 0 {
1514 warnings.push(format!(
1515 "{transformer_charging} transformer charging admittance record(s) dropped: PSLF transformer records written here carry series impedance, tap, shift, and ratings only"
1516 ));
1517 }
1518 let current_ratings = net
1519 .branches
1520 .iter()
1521 .filter(|b| b.current_ratings.is_some())
1522 .count();
1523 if current_ratings > 0 {
1524 warnings.push(format!(
1525 "{current_ratings} branch current rating record(s) dropped: PSLF branch records written here carry MVA ratings only"
1526 ));
1527 }
1528 warn_extra_branch_rating_sets("PSLF .epc", net, &mut warnings);
1529 let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
1530 if branch_solutions > 0 {
1531 warnings.push(format!(
1532 "{branch_solutions} branch solution value set(s) dropped: PSLF solved flow fields are not written"
1533 ));
1534 }
1535 let dropped_reg = net
1538 .generators
1539 .iter()
1540 .filter(|g| g.regulated_bus.is_some())
1541 .count();
1542 if dropped_reg > 0 {
1543 warnings.push(format!(
1544 "{dropped_reg} generator(s) lost their remote regulated bus: the PSLF .epc generator \
1545 record this writer emits controls the unit's own terminal"
1546 ));
1547 }
1548 let drops_winding_detail = net.transformers_3w.iter().any(|t| {
1551 t.windings[1..]
1552 .iter()
1553 .any(|w| (w.tap - 1.0).abs() > 1e-9 || w.rate_a.abs() > 1e-9)
1554 });
1555 if drops_winding_detail {
1556 warnings.push(
1557 "PSLF 3-winding export carries the primary winding ratio/ratings only; \
1558 secondary/tertiary winding ratios/ratings dropped"
1559 .into(),
1560 );
1561 }
1562 let dropped_control = net.branches.iter().filter(|b| b.control.is_some()).count();
1565 if dropped_control > 0 {
1566 warnings.push(format!(
1567 "{dropped_control} transformer(s) lost their regulating control (mode/tap limits/\
1568 regulated bus): the PSLF .epc transformer record carries no control columns"
1569 ));
1570 }
1571 let dropped_sw = net.shunts.iter().filter(|s| s.control.is_some()).count();
1574 if dropped_sw > 0 {
1575 warnings.push(format!(
1576 "{dropped_sw} switched shunt(s) written as fixed: the PSLF .epc shunt record this \
1577 writer emits has no switching-control columns (mode/band/step blocks)"
1578 ));
1579 }
1580 let sanitized = sanitized_names + sanitized_ids;
1581 if sanitized > 0 {
1582 warnings.push(format!(
1583 "{sanitized} quoted field(s) contained a double quote that would corrupt an EPC \
1584 record; replaced with spaces"
1585 ));
1586 }
1587 if nonfinite {
1588 warnings.push("non-finite values written as ±1e10 sentinels (PSLF has no Inf/NaN)".into());
1589 }
1590
1591 Conversion { text: s, warnings }
1592}
1593
1594fn pslf_type(kind: BusType) -> u8 {
1596 match kind {
1597 BusType::Ref => 0,
1598 BusType::Pv => 2,
1599 BusType::Isolated => 4,
1600 BusType::Pq => 1,
1601 }
1602}
1603
1604fn device_id(
1608 extras: &Extras,
1609 bus: BusId,
1610 used: &mut BTreeMap<BusId, BTreeSet<String>>,
1611 sanitized: &mut usize,
1612) -> String {
1613 let preferred = extras
1614 .get("id")
1615 .and_then(Value::as_str)
1616 .map(str::trim)
1617 .filter(|id| !id.is_empty())
1618 .map(|id| {
1619 let clean = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
1620 if matches!(clean, std::borrow::Cow::Owned(_)) {
1621 *sanitized += 1;
1622 }
1623 clean.into_owned()
1624 });
1625 super::allocate_circuit_id(preferred.as_deref(), bus, used)
1626}
1627
1628fn circuit_tok(extras: &Extras) -> String {
1631 let ck = extras
1632 .get("pslf_circuit")
1633 .and_then(Value::as_str)
1634 .unwrap_or("1");
1635 let clean = sanitize_quoted(ck, &['"', ':', ' ', '\t', '/'], '_');
1639 format!("\"{clean}\"")
1640}
1641
1642fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
1646 extras
1647 .get(key)
1648 .and_then(Value::as_f64)
1649 .filter(|v| v.is_finite())
1650}
1651
1652fn same_load_total(a: f64, b: f64) -> bool {
1653 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
1654}
1655
1656fn load_components_for_write(
1657 l: &Load,
1658 warnings: &mut Vec<String>,
1659) -> (f64, f64, f64, f64, f64, f64) {
1660 if let Some(LoadVoltageModel::Zip {
1661 p_constant_power,
1662 q_constant_power,
1663 p_constant_current,
1664 q_constant_current,
1665 p_constant_impedance,
1666 q_constant_impedance,
1667 v_nom,
1668 load_type,
1669 scaling,
1670 ..
1671 }) = &l.voltage_model
1672 {
1673 if same_load_total(
1674 p_constant_power + p_constant_current + p_constant_impedance,
1675 l.p,
1676 ) && same_load_total(
1677 q_constant_power + q_constant_current + q_constant_impedance,
1678 l.q,
1679 ) {
1680 if v_nom.is_some() {
1681 warnings.push(format!(
1682 "PSLF load at bus {}: nominal voltage has no load data field; dropped",
1683 l.bus
1684 ));
1685 }
1686 if load_type.is_some() || scaling.is_some() {
1687 warnings.push(format!(
1688 "PSLF load at bus {}: PSS/E load type/scaling has no load data field; dropped",
1689 l.bus
1690 ));
1691 }
1692 return (
1693 *p_constant_power,
1694 *q_constant_power,
1695 *p_constant_current,
1696 *q_constant_current,
1697 *p_constant_impedance,
1698 *q_constant_impedance,
1699 );
1700 }
1701 warnings.push(format!(
1702 "PSLF load at bus {}: stale voltage model components did not match typed p/q; wrote typed p/q as constant power",
1703 l.bus
1704 ));
1705 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
1706 }
1707 if matches!(l.voltage_model, Some(LoadVoltageModel::Exponential { .. })) {
1708 warnings.push(format!(
1709 "PSLF load at bus {}: exponential voltage model has no PSLF load data columns; wrote typed p/q as constant power",
1710 l.bus
1711 ));
1712 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
1713 }
1714
1715 let mw = extra_f64(&l.extras, "pslf_mw").unwrap_or(l.p);
1718 let mvar = extra_f64(&l.extras, "pslf_mvar").unwrap_or(l.q);
1719 let mw_i = extra_f64(&l.extras, "pslf_mw_i").unwrap_or(0.0);
1720 let mvar_i = extra_f64(&l.extras, "pslf_mvar_i").unwrap_or(0.0);
1721 let mw_z = extra_f64(&l.extras, "pslf_mw_z").unwrap_or(0.0);
1722 let mvar_z = extra_f64(&l.extras, "pslf_mvar_z").unwrap_or(0.0);
1723 if l.extras.keys().any(|key| {
1724 matches!(
1725 key.as_str(),
1726 "pslf_mw" | "pslf_mvar" | "pslf_mw_i" | "pslf_mvar_i" | "pslf_mw_z" | "pslf_mvar_z"
1727 )
1728 }) && (!same_load_total(mw + mw_i + mw_z, l.p)
1729 || !same_load_total(mvar + mvar_i + mvar_z, l.q))
1730 {
1731 warnings.push(format!(
1732 "PSLF load at bus {}: stale PSLF load extras did not match typed p/q; wrote typed p/q as constant power",
1733 l.bus
1734 ));
1735 return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
1736 }
1737 (mw, mvar, mw_i, mvar_i, mw_z, mvar_z)
1738}
1739
1740fn safe_div(a: f64, b: f64) -> f64 {
1742 if b.is_finite() && b != 0.0 {
1743 a / b
1744 } else {
1745 0.0
1746 }
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751 use super::*;
1752
1753 fn close(actual: f64, expected: f64) {
1754 assert!((actual - expected).abs() < 1e-9, "{actual} != {expected}");
1755 }
1756
1757 #[test]
1758 fn reads_minimal_epc_core() {
1759 let epc = r#"title
1760minimal
1761!
1762solution parameters
1763sbase 100.0000
1764jump 0.000290
1765!
1766bus data [2] ty vsched volt angle ar zone vmax vmin date_in date_out pid L own st
17671 "Slack " 230.0000 : 0 1.0000 1.0000 0.0 1 1 1.1 0.9 400101 391231 0 0 1 0
17682 "Load " 230.0000 : 1 1.0000 1.0000 -1.0 1 1 1.1 0.9 400101 391231 0 0 1 0
1769branch data [1] ck se long_id st resist react charge rate1 rate2 rate3 rate4 aloss lngth
17701 "Slack " 230.00 2 "Load " 230.00 "1 " 1 "line" : 1 0.01 0.05 0.001 100 90 80 0 0 1 /
17711 1 0 0
1772generator data [1] id long_id st no reg_name prf qrf ar zone pgen pmax pmin qgen qmax qmin mbase
17731 "Slack " 230.00 "1 " "gen" : 1 1 "Slack " 230.00 0 1 1 1 50 80 0 5 30 -20 100 /
17740
1775load data [1] id long_id st mw mvar mw_i mvar_i mw_z mvar_z ar zone
17762 "Load " 230.00 "1 " "load" : 1 10 3 1 0.5 2 1.5 1 1
1777shunt data [1] id ck se long_id st ar zone pu_mw pu_mvar
17782 "Load " 230.00 "b " 0 "" 0.00 " " 0 "" : 1 1 1 0.00 0.10
1779end
1780"#;
1781
1782 let mut warnings = Vec::new();
1783 let net = parse_pslf_source(Arc::new(epc.to_string()), None, &mut warnings).unwrap();
1784
1785 assert_eq!(net.source_format, SourceFormat::Pslf);
1786 assert_eq!(net.buses.len(), 2);
1787 assert_eq!(net.branches.len(), 1);
1788 assert_eq!(net.loads.len(), 1);
1789 assert_eq!(net.generators.len(), 1);
1790 assert_eq!(net.shunts.len(), 1);
1791 assert_eq!(net.buses[0].kind, BusType::Ref);
1792 close(net.loads[0].p, 13.0);
1793 close(net.loads[0].q, 5.0);
1794 close(net.shunts[0].b, 10.0);
1795 assert!(warnings.iter().any(|w| w.contains("ZIP load")));
1796 }
1797
1798 #[test]
1799 fn same_source_text_is_retained() {
1800 let epc = "title\nx\n!\nsolution parameters\nsbase 100\n!\nbus data [1]\n1 \"A\" 1 : 0 1 1 0 1 1 1.1 0.9\nend\n";
1801 let mut warnings = Vec::new();
1802 let net = parse_pslf_source(Arc::new(epc.to_string()), None, &mut warnings).unwrap();
1803 assert_eq!(net.source.as_deref().map(String::as_str), Some(epc));
1804 }
1805
1806 #[test]
1807 fn transformer_charging_drop_is_warned_on_write() {
1808 let mut net = BalancedNetwork::in_memory(
1809 "charging",
1810 100.0,
1811 vec![
1812 Bus {
1813 id: BusId(1),
1814 kind: BusType::Ref,
1815 vm: 1.0,
1816 va: 0.0,
1817 base_kv: 230.0,
1818 vmax: 1.1,
1819 vmin: 0.9,
1820 evhi: None,
1821 evlo: None,
1822 area: 1,
1823 zone: 1,
1824 name: None,
1825 uid: None,
1826 location: None,
1827 extras: Extras::new(),
1828 },
1829 Bus {
1830 id: BusId(2),
1831 kind: BusType::Pq,
1832 vm: 1.0,
1833 va: 0.0,
1834 base_kv: 230.0,
1835 vmax: 1.1,
1836 vmin: 0.9,
1837 evhi: None,
1838 evlo: None,
1839 area: 1,
1840 zone: 1,
1841 name: None,
1842 uid: None,
1843 location: None,
1844 extras: Extras::new(),
1845 },
1846 ],
1847 Vec::new(),
1848 );
1849 net.branches.push(Branch {
1850 from: BusId(1),
1851 to: BusId(2),
1852 r: 0.01,
1853 x: 0.1,
1854 b: 0.02,
1855 charging: None,
1856 rate_a: 100.0,
1857 rate_b: 100.0,
1858 rate_c: 100.0,
1859 rating_sets: Vec::new(),
1860 current_ratings: None,
1861 tap: 1.0,
1862 shift: 0.0,
1863 in_service: true,
1864 angmin: -360.0,
1865 angmax: 360.0,
1866 control: None,
1867 solution: None,
1868 uid: None,
1869 route: None,
1870 extras: Extras::new(),
1871 });
1872
1873 let conv = write_pslf(&net);
1874 assert!(
1875 conv.warnings
1876 .iter()
1877 .any(|w| w.contains("transformer charging admittance")),
1878 "{:?}",
1879 conv.warnings
1880 );
1881 }
1882
1883 #[test]
1884 fn clean_line_continuation_slash_respects_quotes() {
1885 assert_eq!(clean_line(r#"1 "A" : 0 /"#), (r#"1 "A" : 0"#.into(), true));
1886 assert_eq!(
1887 clean_line(r#"1 "name/" : 0"#),
1888 (r#"1 "name/" : 0"#.into(), false)
1889 );
1890 assert_eq!(
1891 clean_line(r#"1 "unterminated /"#),
1892 (r#"1 "unterminated /"#.into(), false)
1893 );
1894 assert_eq!(
1895 clean_line(r#"1 "has ""quote""" : 0 /"#),
1896 (r#"1 "has ""quote""" : 0"#.into(), true)
1897 );
1898 }
1899
1900 #[test]
1901 fn pslf_tokens_keep_slashes_inside_quoted_names() {
1902 assert_eq!(
1903 tokens(r#"1 "A/B" 230.0 : 0"#),
1904 vec!["1", "A/B", "230.0", ":", "0"]
1905 );
1906 }
1907
1908 #[test]
1909 fn parse_id_accepts_only_integer_values() {
1910 assert_eq!(parse_id("12"), Some(12));
1911 assert_eq!(parse_id("12.0"), Some(12));
1912 assert_eq!(parse_id("1e3"), Some(1000));
1913 assert_eq!(parse_id("12.9"), None);
1914 assert_eq!(parse_id("-1"), None);
1915 assert_eq!(parse_id("NaN"), None);
1916 }
1917}