Skip to main content

powerio/format/
pslf.rs

1//! Read and write legacy GE PSLF `.epc` power flow cases.
2//!
3//! EPC files contain named data sections with colon separated record bodies.
4//! The reader keeps raw physical lines plus token lists on both sides of each
5//! colon, then maps the static power flow core into [`BalancedNetwork`]. Records outside
6//! that model stay in retained source text and read warnings. [`write_pslf`]
7//! inverts the reader's column layout for the cross-format write path (same
8//! format writes echo the retained source).
9
10use 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
25/// The double quote delimits an EPC name token, and the reader's tokenizer
26/// toggles on it with no un-escaping, so an embedded quote would shift the record.
27const NAME_FORBIDDEN: &[char] = &['"'];
28
29/// Parse a PSLF `.epc` case into a [`BalancedNetwork`].
30///
31/// Read warnings are available through the shared [`crate::parse_file`] /
32/// [`crate::parse_str`] entry points. This direct helper keeps the older
33/// format-module convention and returns only the typed network.
34pub 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
39/// Parse retained source from the format hub.
40pub(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/// EPC source document: structural parse of the file before mapping to
138/// [`BalancedNetwork`].
139///
140/// This intentionally keeps sections as raw records instead of making a PSLF
141/// specific object model. The reader only maps the static power flow sections;
142/// everything else remains in `source` and is surfaced through warnings.
143#[derive(Debug)]
144struct EpcDocument {
145    title: Vec<String>,
146    solution_parameters: Vec<String>,
147    sections: BTreeMap<String, Section>,
148}
149
150impl EpcDocument {
151    /// Choose the case name from the title block, falling back to the file stem.
152    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    /// Return records from a named EPC section, or an empty slice when absent.
162    fn records(&self, section: &str) -> &[Record] {
163        self.sections
164            .get(section)
165            .map_or(&[], |section| section.records.as_slice())
166    }
167
168    /// Read `sbase` from solution parameters, defaulting to 100 MVA.
169    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    /// Read the optional branch reactance jump threshold.
186    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/// One named `... data [count]` block.
198///
199/// `declared_count` is retained because count mismatches are useful evidence
200/// when a variant section shape appears in a new EPC file.
201#[derive(Debug)]
202struct Section {
203    declared_count: usize,
204    header: String,
205    records: Vec<Record>,
206}
207
208/// One logical EPC record assembled from one or more physical lines.
209///
210/// `lhs` is the identity side before `:`, and `rhs` is the numeric/status side.
211/// Raw physical lines stay attached so conversion warnings and extras can point
212/// back to the original text.
213#[derive(Debug)]
214struct Record {
215    line_no: usize,
216    raw: Vec<String>,
217    lhs: Vec<String>,
218    rhs: Vec<String>,
219}
220
221/// Parse EPC's section grammar without interpreting electrical fields.
222///
223/// The general structure is stable across observed files: free text blocks end
224/// with `!`, data sections declare a count in brackets, and `/` continues a
225/// record onto the next physical line. The parser stops early at the next
226/// section header and reports the mismatch instead of consuming unrelated text.
227#[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
344/// Parse a `name data [count] ...` section header.
345///
346/// The returned name is lower case so callers can use stable section keys
347/// across files that vary capitalization.
348fn 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
359/// Strip line endings and detect EPC continuation lines.
360///
361/// A trailing `/` outside a quoted string joins the next physical line into
362/// the same logical record.
363fn 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
394/// Tokenize a logical record and split it into identity and value sides.
395fn split_record(raw_lines: &[String]) -> (Vec<String>, Vec<String>) {
396    let toks = tokens(&raw_lines.join(" "));
397    split_tokens(toks)
398}
399
400/// Split already tokenized fields at the first unquoted `:`.
401fn 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
409/// Tokenize an EPC line while preserving quoted strings as one token.
410///
411/// Double quotes inside a quoted string are escaped by doubling them.
412fn 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
450/// Return the right side tokens for one physical line in a multi-line record.
451fn 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
458/// Return all tokens for one physical line in a multi-line record.
459fn line_tokens(rec: &Record, line: usize) -> Vec<String> {
460    rec.raw.get(line).map_or_else(Vec::new, |line| tokens(line))
461}
462
463/// Map one `bus data` record into a [`Bus`].
464fn 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
486/// Convert PSLF bus type codes to the format neutral bus type enum.
487fn 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
496/// Map one `branch data` record into a line [`Branch`].
497fn 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/// One mapped `transformer data` record: a 2-winding becomes a [`Branch`], a
531/// 3-winding becomes a [`Transformer3W`].
532// The 3-winding variant is the larger; boxing it to equalize the variants would
533// add an allocation per record for no real benefit at this size.
534#[allow(clippy::large_enum_variant)]
535enum TransformerRecord {
536    TwoWinding(Branch),
537    ThreeWinding(Transformer3W),
538}
539
540/// Map one `transformer data` record. A tertiary winding (a nonzero tertiary bus
541/// or any primary-tertiary / secondary-tertiary impedance) makes it a
542/// [`Transformer3W`]; otherwise it is a two-winding [`Branch`].
543///
544/// The `.epc` record carries the three pairwise impedances and the primary
545/// winding's ratio/ratings; the secondary and tertiary winding ratios are not
546/// represented at these column positions, so they default to nominal.
547fn 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            // z12 = primary-secondary, z23 = secondary-tertiary, z31 = tertiary-primary.
607            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
650/// Map one `generator data` record.
651///
652/// EPC stores generator voltage setpoints as controlled kV (`reg_kv`) when
653/// present. Older or sparse rows leave it zero, so fall back to the solved bus
654/// voltage.
655fn 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
691/// Map one `load data` record.
692///
693/// Constant current and impedance components are folded into total P/Q for
694/// static load aggregation and preserved in the typed voltage model.
695fn 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        // Fold components into P/Q so matrix builders see the total demand that
709        // the solved power flow used. The split stays typed for richer writers.
710        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
744/// Map one fixed `shunt data` record and convert per unit G/B to MW/MVAr.
745fn 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
763/// Map one `svd data` record as a fixed shunt at its initial G/B value.
764///
765/// The control target, limits, and switching fields stay in extras until
766/// `BalancedNetwork` grows a typed controlled shunt model.
767fn 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/// Converter side of a PSLF DC line.
798///
799/// EPC stores AC converter rows separately from the DC line row. This holds the
800/// AC terminal and setpoints until the line join happens.
801#[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
811/// Read all `dc converter data` rows into a DC bus keyed map.
812///
813/// Malformed converter rows become warnings so unrelated AC data in the same
814/// file can still be read.
815fn 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
847/// Map two-terminal DC lines through their converter rows.
848///
849/// EPC separates the DC line from each AC converter. `BalancedNetwork::Hvdc` needs AC
850/// terminal buses and setpoints on one row, so this joins by DC bus id and
851/// retains converter extras under the HVDC record.
852fn 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
925/// Report nonempty EPC sections that are retained as source text only.
926fn 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
948/// Common extras for mapped EPC rows.
949///
950/// The `used_*` bounds are the fields consumed by the typed reader. Remaining
951/// tokens are retained so later PSLF work can recover more fields without
952/// needing the original case file at hand.
953fn 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
973/// Capture a load/shunt/svd record's id (lhs token 3) into `extras["id"]` — the
974/// key the PSS/E reader uses — so the id survives cross-format writes and
975/// parallel devices on a bus stay distinguishable.
976fn 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
982/// Convert strings to a JSON array for `extras`.
983fn string_array(values: impl IntoIterator<Item = String>) -> Value {
984    Value::Array(values.into_iter().map(Value::String).collect())
985}
986
987/// Preserve an EPC token as a number when it parses, otherwise as a string.
988fn 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
995/// Convert a finite f64 to JSON, using null for nonfinite values.
996fn number_value(value: f64) -> Value {
997    Number::from_f64(value).map_or(Value::Null, Value::Number)
998}
999
1000/// Read an optional floating point field with a default for omitted values.
1001fn 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
1008/// Read an optional integer field with a default for omitted values.
1009fn 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
1016/// Read an optional nonnegative numeric identifier.
1017fn 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
1024/// Read a required nonnegative numeric identifier.
1025fn 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
1035/// Parse PSLF numeric identifiers, including integer-valued floating text.
1036fn 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
1047/// Read a numeric status field as an in service boolean.
1048fn 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
1052/// Build a field-level parse error with the source line number.
1053fn 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// ---- Writer -----------------------------------------------------------------
1064
1065/// Per-bus identity the EPC `lhs` carries on every element record.
1066#[derive(Clone, Copy)]
1067struct BusRef<'a> {
1068    name: &'a str,
1069    base_kv: f64,
1070    area: usize,
1071    zone: usize,
1072}
1073
1074/// Serialize `net` to PSLF `.epc` text.
1075///
1076/// The inverse of the reader's column layout: it emits the same colon separated
1077/// `lhs : rhs` records, so a `.epc` -> [`BalancedNetwork`] -> `.epc` round trip preserves
1078/// the power flow core. Where a PSLF read stashed a field the neutral model does
1079/// not name under a `pslf_*` extras key (the ZIP load split, the per unit shunt
1080/// G/B, the branch circuit id, the transformer winding base), the writer replays
1081/// it; otherwise it synthesizes the column. Same-format byte-exact echo rides the
1082/// retained source (see [`crate::write_as`]); this is the cross format path and
1083/// the fallback when the source text was dropped (e.g. after a JSON round trip).
1084#[must_use]
1085// A flat serializer: one stanza per EPC section; splitting it would add
1086// indirection without clarity.
1087#[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    // Bus identity for the lhs of every downstream record, keyed by source id.
1112    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    // A quoted, sanitized name token; counts substitutions for the warning.
1136    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    // ---- header blocks ----
1145    let _ = writeln!(s, "title");
1146    // The title is one record; a terminator would end it and put the rest of
1147    // the name where the parser expects the next block.
1148    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    // ---- bus data ----
1158    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    // ---- load data ----
1182    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        // Parallel loads on one bus get distinct ids; a captured `extras["id"]`
1189        // (from a PSS/E or PSLF source) wins, else positional.
1190        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    // ---- shunt data ----
1216    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        // Same per-bus id rule as loads: `(bus, id)` must stay unique.
1223        let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
1224        for sh in &net.shunts {
1225            let r = bus_ref(sh.bus);
1226            // PSLF stores shunt G/B per unit on the system base; replay the read
1227            // values when present, else divide the MW/MVAr-at-1pu back out.
1228            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    // ---- branch data (non-transformer) ----
1251    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        // Parallel branches between the same bus pair get distinct circuit ids; a
1263        // captured `pslf_circuit` (from a PSLF source) wins, else positional.
1264        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    // ---- transformer data (2- and 3-winding, one section) ----
1294    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            // First physical line: identity lhs, then the 21-field rhs the reader
1303            // indexes (status 0, tertiary 9 = 0, base 14, R 15, X 16, and the
1304            // pt/ts tertiary impedances 17-20 = 0 to mark a 2-winding unit). The
1305            // trailing `/` continues the record onto the second line.
1306            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            // Second physical line: ratings at 6-8, phase shift at 10, tap at 16.
1324            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            // The tertiary bus rides field 9; the pairwise impedances fill the
1337            // primary-secondary slot (15-16) and the primary-tertiary (17-18) and
1338            // secondary-tertiary (19-20) slots the reader keys off to detect a 3W.
1339            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            // Only the primary winding's ratio/ratings have a column here.
1362            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    // ---- generator data ----
1373    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            // rhs indices the reader reads: status 0, reg_kv 3, pgen 8, pmax 9,
1383            // pmin 10, qgen 11, qmax 12, qmin 13, mbase 14. `reg_name` is left as
1384            // 0 because this writer only represents own-terminal regulation.
1385            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    // ---- dc converter + dc line data (two-terminal HVDC) ----
1417    // EPC keeps the AC converter rows separate from the DC line that joins them,
1418    // keyed by a DC bus number. Synthesize a distinct DC bus per converter (these
1419    // are internal join keys, not AC buses) and emit the from/to converter rows
1420    // plus the line row that read_dc_converters/read_dc_lines rejoin into one
1421    // `BalancedNetwork::Hvdc`.
1422    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                // Line 1 carries the AC bus (lhs 0), the DC bus (lhs 3), and the
1435                // status (rhs 0); the trailing `/` continues onto line 2, which
1436                // carries p (token 2) and q (token 3).
1437                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            // The reader reads the status (rhs 0) and rate1 (rhs 6); rate1 sets the
1456            // power limit, falling back to |p| when nonpositive, so emit pmax.
1457            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    // ---- fidelity warnings ----
1471    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    // Transformer branches drop their charging entirely (warned separately
1492    // below), so exclude them here: only line records carry the collapsed total
1493    // susceptance this message describes.
1494    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    // The generator record this writer emits regulates the unit's own terminal, so
1536    // a generator pointing at a remote regulated bus loses that target.
1537    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    // A 3-winding record here carries only the primary winding's ratio/ratings, so
1549    // report any non-nominal secondary/tertiary winding as a fidelity loss.
1550    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    // The `.epc` transformer record this writer emits has no regulating-control
1563    // columns (mode/limits/regulated bus), so a Branch carrying control loses it.
1564    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    // Switched shunts write as fixed `.epc` shunts (G/B); the switching control
1572    // has no column in the shunt record this writer emits.
1573    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
1594/// Neutral bus kind -> PSLF bus type code (inverse of [`pslf_bus_type`]).
1595fn 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
1604/// A per-bus unique load/shunt id: the captured `extras["id"]` (trimmed,
1605/// sanitized) when still free on this bus, else the lowest free positional id,
1606/// so parallel devices keep the `(bus, id)` uniqueness the EPC section requires.
1607fn 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
1628/// The branch/transformer circuit id token, replayed from `pslf_circuit` when a
1629/// PSLF read kept it, else `"1"`.
1630fn circuit_tok(extras: &Extras) -> String {
1631    let ck = extras
1632        .get("pslf_circuit")
1633        .and_then(Value::as_str)
1634        .unwrap_or("1");
1635    // Sanitized like `device_id` and `name_tok`: an unfiltered quote or
1636    // separator here shifts every later column of the branch record. `:`
1637    // splits the EPC lhs/rhs, so it breaks the record too.
1638    let clean = sanitize_quoted(ck, &['"', ':', ' ', '\t', '/'], '_');
1639    format!("\"{clean}\"")
1640}
1641
1642/// A numeric `pslf_*` extra, if present and finite. A non-finite value yields
1643/// `None` so the caller falls back to its synthesized default rather than
1644/// replaying a `NaN`/`±Inf` into the record.
1645fn 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    // Replay the ZIP split a PSLF read preserved before this release; otherwise
1716    // put the whole demand in the constant power column.
1717    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
1740/// `a / b`, or 0 when `b` is not a usable divisor (the identity for an absent base).
1741fn 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}