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 [`Network`]. 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    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
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 [`Network`].
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<Network> {
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<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/// Structural parse of an EPC file before mapping to [`Network`].
137///
138/// This intentionally keeps sections as raw records instead of making a PSLF
139/// specific object model. The reader only maps the static power flow sections;
140/// everything else remains in `source` and is surfaced through warnings.
141#[derive(Debug)]
142struct EpcDocument {
143    title: Vec<String>,
144    solution_parameters: Vec<String>,
145    sections: BTreeMap<String, Section>,
146}
147
148impl EpcDocument {
149    /// Choose the case name from the title block, falling back to the file stem.
150    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    /// Return records from a named EPC section, or an empty slice when absent.
160    fn records(&self, section: &str) -> &[Record] {
161        self.sections
162            .get(section)
163            .map_or(&[], |section| section.records.as_slice())
164    }
165
166    /// Read `sbase` from solution parameters, defaulting to 100 MVA.
167    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    /// Read the optional branch reactance jump threshold.
184    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/// One named `... data [count]` block.
196///
197/// `declared_count` is retained because count mismatches are useful evidence
198/// when a variant section shape appears in a new EPC file.
199#[derive(Debug)]
200struct Section {
201    declared_count: usize,
202    header: String,
203    records: Vec<Record>,
204}
205
206/// One logical EPC record assembled from one or more physical lines.
207///
208/// `lhs` is the identity side before `:`, and `rhs` is the numeric/status side.
209/// Raw physical lines stay attached so conversion warnings and extras can point
210/// back to the original text.
211#[derive(Debug)]
212struct Record {
213    line_no: usize,
214    raw: Vec<String>,
215    lhs: Vec<String>,
216    rhs: Vec<String>,
217}
218
219/// Parse EPC's section grammar without interpreting electrical fields.
220///
221/// The general structure is stable across observed files: free text blocks end
222/// with `!`, data sections declare a count in brackets, and `/` continues a
223/// record onto the next physical line. The parser stops early at the next
224/// section header and reports the mismatch instead of consuming unrelated text.
225#[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
342/// Parse a `name data [count] ...` section header.
343///
344/// The returned name is lower case so callers can use stable section keys
345/// across files that vary capitalization.
346fn 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
357/// Strip line endings and detect EPC continuation lines.
358///
359/// A trailing `/` outside a quoted string joins the next physical line into
360/// the same logical record.
361fn 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
392/// Tokenize a logical record and split it into identity and value sides.
393fn split_record(raw_lines: &[String]) -> (Vec<String>, Vec<String>) {
394    let toks = tokens(&raw_lines.join(" "));
395    split_tokens(toks)
396}
397
398/// Split already tokenized fields at the first unquoted `:`.
399fn 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
407/// Tokenize an EPC line while preserving quoted strings as one token.
408///
409/// Double quotes inside a quoted string are escaped by doubling them.
410fn 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
448/// Return the right side tokens for one physical line in a multi-line record.
449fn 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
456/// Return all tokens for one physical line in a multi-line record.
457fn line_tokens(rec: &Record, line: usize) -> Vec<String> {
458    rec.raw.get(line).map_or_else(Vec::new, |line| tokens(line))
459}
460
461/// Map one `bus data` record into a [`Bus`].
462fn 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
483/// Convert PSLF bus type codes to the format neutral bus type enum.
484fn 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
493/// Map one `branch data` record into a line [`Branch`].
494fn 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/// One mapped `transformer data` record: a 2-winding becomes a [`Branch`], a
527/// 3-winding becomes a [`Transformer3W`].
528// The 3-winding variant is the larger; boxing it to equalize the variants would
529// add an allocation per record for no real benefit at this size.
530#[allow(clippy::large_enum_variant)]
531enum TransformerRecord {
532    TwoWinding(Branch),
533    ThreeWinding(Transformer3W),
534}
535
536/// Map one `transformer data` record. A tertiary winding (a nonzero tertiary bus
537/// or any primary-tertiary / secondary-tertiary impedance) makes it a
538/// [`Transformer3W`]; otherwise it is a two-winding [`Branch`].
539///
540/// The `.epc` record carries the three pairwise impedances and the primary
541/// winding's ratio/ratings; the secondary and tertiary winding ratios are not
542/// represented at these column positions, so they default to nominal.
543fn 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            // z12 = primary-secondary, z23 = secondary-tertiary, z31 = tertiary-primary.
603            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
645/// Map one `generator data` record.
646///
647/// EPC stores generator voltage setpoints as controlled kV (`reg_kv`) when
648/// present. Older or sparse rows leave it zero, so fall back to the solved bus
649/// voltage.
650fn 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
686/// Map one `load data` record.
687///
688/// Constant current and impedance components are folded into total P/Q for
689/// static load aggregation and preserved in the typed voltage model.
690fn 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        // Fold components into P/Q so matrix builders see the total demand that
704        // the solved power flow used. The split stays typed for richer writers.
705        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
739/// Map one fixed `shunt data` record and convert per unit G/B to MW/MVAr.
740fn 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
758/// Map one `svd data` record as a fixed shunt at its initial G/B value.
759///
760/// The control target, limits, and switching fields stay in extras until
761/// `Network` grows a typed controlled shunt model.
762fn 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/// Converter side of a PSLF DC line.
793///
794/// EPC stores AC converter rows separately from the DC line row. This holds the
795/// AC terminal and setpoints until the line join happens.
796#[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
806/// Read all `dc converter data` rows into a DC bus keyed map.
807///
808/// Malformed converter rows become warnings so unrelated AC data in the same
809/// file can still be read.
810fn 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
842/// Map two-terminal DC lines through their converter rows.
843///
844/// EPC separates the DC line from each AC converter. `Network::Hvdc` needs AC
845/// terminal buses and setpoints on one row, so this joins by DC bus id and
846/// retains converter extras under the HVDC record.
847fn 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
920/// Report nonempty EPC sections that are retained as source text only.
921fn 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
943/// Common extras for mapped EPC rows.
944///
945/// The `used_*` bounds are the fields consumed by the typed reader. Remaining
946/// tokens are retained so later PSLF work can recover more fields without
947/// needing the original case file at hand.
948fn 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
968/// Capture a load/shunt/svd record's id (lhs token 3) into `extras["id"]` — the
969/// key the PSS/E reader uses — so the id survives cross-format writes and
970/// parallel devices on a bus stay distinguishable.
971fn 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
977/// Convert strings to a JSON array for `extras`.
978fn string_array(values: impl IntoIterator<Item = String>) -> Value {
979    Value::Array(values.into_iter().map(Value::String).collect())
980}
981
982/// Preserve an EPC token as a number when it parses, otherwise as a string.
983fn 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
990/// Convert a finite f64 to JSON, using null for nonfinite values.
991fn number_value(value: f64) -> Value {
992    Number::from_f64(value).map_or(Value::Null, Value::Number)
993}
994
995/// Read an optional floating point field with a default for omitted values.
996fn 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
1003/// Read an optional integer field with a default for omitted values.
1004fn 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
1011/// Read an optional nonnegative numeric identifier.
1012fn 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
1019/// Read a required nonnegative numeric identifier.
1020fn 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
1030/// Parse PSLF numeric identifiers, including integer-valued floating text.
1031fn 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
1042/// Read a numeric status field as an in service boolean.
1043fn 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
1047/// Build a field-level parse error with the source line number.
1048fn 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// ---- Writer -----------------------------------------------------------------
1059
1060/// Per-bus identity the EPC `lhs` carries on every element record.
1061#[derive(Clone, Copy)]
1062struct BusRef<'a> {
1063    name: &'a str,
1064    base_kv: f64,
1065    area: usize,
1066    zone: usize,
1067}
1068
1069/// Serialize `net` to PSLF `.epc` text.
1070///
1071/// The inverse of the reader's column layout: it emits the same colon separated
1072/// `lhs : rhs` records, so a `.epc` -> [`Network`] -> `.epc` round trip preserves
1073/// the power flow core. Where a PSLF read stashed a field the neutral model does
1074/// not name under a `pslf_*` extras key (the ZIP load split, the per unit shunt
1075/// G/B, the branch circuit id, the transformer winding base), the writer replays
1076/// it; otherwise it synthesizes the column. Same-format byte-exact echo rides the
1077/// retained source (see [`crate::write_as`]); this is the cross-format path and
1078/// the fallback when the source text was dropped (e.g. after a JSON round trip).
1079#[must_use]
1080// A flat serializer: one stanza per EPC section; splitting it would add
1081// indirection without clarity.
1082#[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    // Bus identity for the lhs of every downstream record, keyed by source id.
1107    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    // A quoted, sanitized name token; counts substitutions for the warning.
1131    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    // ---- header blocks ----
1140    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    // ---- bus data ----
1151    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    // ---- load data ----
1175    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        // Parallel loads on one bus get distinct ids; a captured `extras["id"]`
1182        // (from a PSS/E or PSLF source) wins, else positional.
1183        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    // ---- shunt data ----
1209    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        // Same per-bus id rule as loads: `(bus, id)` must stay unique.
1216        let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
1217        for sh in &net.shunts {
1218            let r = bus_ref(sh.bus);
1219            // PSLF stores shunt G/B per unit on the system base; replay the read
1220            // values when present, else divide the MW/MVAr-at-1pu back out.
1221            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    // ---- branch data (non-transformer) ----
1244    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        // Parallel branches between the same bus pair get distinct circuit ids; a
1256        // captured `pslf_circuit` (from a PSLF source) wins, else positional.
1257        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    // ---- transformer data (2- and 3-winding, one section) ----
1287    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            // First physical line: identity lhs, then the 21-field rhs the reader
1296            // indexes (status 0, tertiary 9 = 0, base 14, R 15, X 16, and the
1297            // pt/ts tertiary impedances 17-20 = 0 to mark a 2-winding unit). The
1298            // trailing `/` continues the record onto the second line.
1299            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            // Second physical line: ratings at 6-8, phase shift at 10, tap at 16.
1317            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            // The tertiary bus rides field 9; the pairwise impedances fill the
1330            // primary-secondary slot (15-16) and the primary-tertiary (17-18) and
1331            // secondary-tertiary (19-20) slots the reader keys off to detect a 3W.
1332            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            // Only the primary winding's ratio/ratings have a column here.
1355            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    // ---- generator data ----
1366    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            // rhs indices the reader reads: status 0, reg_kv 3, pgen 8, pmax 9,
1376            // pmin 10, qgen 11, qmax 12, qmin 13, mbase 14. `reg_name` is left as
1377            // 0 because this writer only represents own-terminal regulation.
1378            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    // ---- dc converter + dc line data (two-terminal HVDC) ----
1410    // EPC keeps the AC converter rows separate from the DC line that joins them,
1411    // keyed by a DC bus number. Synthesize a distinct DC bus per converter (these
1412    // are internal join keys, not AC buses) and emit the from/to converter rows
1413    // plus the line row that read_dc_converters/read_dc_lines rejoin into one
1414    // `Network::Hvdc`.
1415    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                // Line 1 carries the AC bus (lhs 0), the DC bus (lhs 3), and the
1428                // status (rhs 0); the trailing `/` continues onto line 2, which
1429                // carries p (token 2) and q (token 3).
1430                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            // The reader reads the status (rhs 0) and rate1 (rhs 6); rate1 sets the
1449            // power limit, falling back to |p| when nonpositive, so emit pmax.
1450            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    // ---- fidelity warnings ----
1464    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    // Transformer branches drop their charging entirely (warned separately
1485    // below), so exclude them here: only line records carry the collapsed total
1486    // susceptance this message describes.
1487    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    // The generator record this writer emits regulates the unit's own terminal, so
1529    // a generator pointing at a remote regulated bus loses that target.
1530    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    // A 3-winding record here carries only the primary winding's ratio/ratings, so
1542    // report any non-nominal secondary/tertiary winding as a fidelity loss.
1543    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    // The `.epc` transformer record this writer emits has no regulating-control
1556    // columns (mode/limits/regulated bus), so a Branch carrying control loses it.
1557    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    // Switched shunts write as fixed `.epc` shunts (G/B); the switching control
1565    // has no column in the shunt record this writer emits.
1566    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
1587/// Neutral bus kind -> PSLF bus type code (inverse of [`pslf_bus_type`]).
1588fn 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
1597/// A per-bus unique load/shunt id: the captured `extras["id"]` (trimmed,
1598/// sanitized) when still free on this bus, else the lowest free positional id,
1599/// so parallel devices keep the `(bus, id)` uniqueness the EPC section requires.
1600fn 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
1621/// The branch/transformer circuit id token, replayed from `pslf_circuit` when a
1622/// PSLF read kept it, else `"1"`.
1623fn 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
1631/// A numeric `pslf_*` extra, if present and finite. A non-finite value yields
1632/// `None` so the caller falls back to its synthesized default rather than
1633/// replaying a `NaN`/`±Inf` into the record.
1634fn 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    // Replay the ZIP split a PSLF read preserved before this release; otherwise
1705    // put the whole demand in the constant power column.
1706    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
1729/// `a / b`, or 0 when `b` is not a usable divisor (the identity for an absent base).
1730fn 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}