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