Skip to main content

powerio/format/
psse.rs

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