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 `BalancedNetwork` 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, BalancedNetwork, Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras,
30    Generator, Hvdc, Impedance, Load, LoadVoltageModel, Shunt, ShuntBlock, SolverParams,
31    SourceFormat, 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: &BalancedNetwork, 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: &BalancedNetwork) -> 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: &BalancedNetwork, 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    // The case name reaches the header line and the title line. Both are
200    // single records, so an embedded terminator would make the rest of the
201    // name parse as bus data.
202    let case_name = sanitize_quoted(&net.name, NAME_FORBIDDEN, ' ');
203    let _ = writeln!(
204        s,
205        "0, {}, {rev}, 0, {}, {}   / powerio export: {}",
206        net.base_mva,
207        i32::from(modern),
208        num(net.base_frequency),
209        case_name
210    );
211    let _ = writeln!(s, "{case_name}");
212    let _ = writeln!(s);
213    if modern {
214        // v34+ system-wide block: emit the solver keyword lines (the fields that
215        // are set), then close the block.
216        if let Some(sp) = &net.solver {
217            if let Some(t) = sp.zero_impedance_threshold {
218                let _ = writeln!(s, "GENERAL, THRSHZ={}", num(t));
219            }
220            let mut newton = Vec::new();
221            if let Some(t) = sp.newton_tolerance {
222                newton.push(format!("TOLN={}", num(t)));
223            }
224            if let Some(n) = sp.max_iterations {
225                newton.push(format!("ITMXN={n}"));
226            }
227            if !newton.is_empty() {
228                let _ = writeln!(s, "NEWTON, {}", newton.join(", "));
229            }
230            let flags: Vec<String> = [
231                ("ACTAPS", sp.adjust_taps),
232                ("AREAIN", sp.adjust_area_interchange),
233                ("PHSHFT", sp.adjust_phase_shift),
234                ("DCTAPS", sp.adjust_dc_taps),
235                ("SWSHNT", sp.adjust_switched_shunt),
236            ]
237            .into_iter()
238            .filter_map(|(name, v)| v.map(|b| format!("{name}={}", i32::from(b))))
239            .collect();
240            if !flags.is_empty() {
241                let _ = writeln!(s, "SOLVER, {}", flags.join(", "));
242            }
243        }
244        let _ = writeln!(s, "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA");
245    }
246
247    // Bus, with area/zone kept for the load records that reference them.
248    let mut bus_area: BTreeMap<BusId, (usize, usize)> = BTreeMap::new();
249    for b in &net.buses {
250        bus_area.insert(b.id, (b.area, b.zone));
251        let raw_name = b.name.as_deref().unwrap_or("");
252        let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
253        if matches!(name, std::borrow::Cow::Owned(_)) {
254            sanitized_quoted += 1;
255        }
256        // The last two columns are EVHI/EVLO; emit the emergency band when set,
257        // else echo the normal band.
258        let _ = writeln!(
259            s,
260            "{}, '{:<12}', {}, {}, {}, {}, 1, {}, {}, {}, {}, {}, {}",
261            b.id,
262            name,
263            num(b.base_kv),
264            ide(b.kind),
265            b.area,
266            b.zone,
267            num(b.vm),
268            num(b.va),
269            num(b.vmax),
270            num(b.vmin),
271            num(b.evhi.unwrap_or(b.vmax)),
272            num(b.evlo.unwrap_or(b.vmin))
273        );
274    }
275    let _ = writeln!(s, "0 / END OF BUS DATA, BEGIN LOAD DATA");
276
277    // v33 ends the load record at INTRPT; v34 adds PDGEN/QDGEN/STDG and v35 a
278    // LOADTYPE string. PSS/E-sourced rows replay these from extras; other
279    // sources get the documented defaults.
280    // Per-bus circuit-id counters so parallel devices on a bus get distinct ids
281    // (PSS/E requires (bus, id) to be unique); a captured `extras["id"]` wins.
282    let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
283    for l in &net.loads {
284        let (area, zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
285        let id = quoted_device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_quoted);
286        let (pl, ql, ip, iq, yp, yq) = load_components_for_write(l, &id, &mut warnings);
287        let owner = extra_i64(&l.extras, "psse_owner").unwrap_or(1);
288        let scal = typed_psse_scal(l, &id, &mut warnings)
289            .or_else(|| extra_i64(&l.extras, "psse_scal"))
290            .unwrap_or(1);
291        let intrpt = extra_i64(&l.extras, "psse_intrpt").unwrap_or(0);
292        let typed_load_type = l.voltage_model.as_ref().and_then(typed_psse_load_type);
293        if rev < 35 && typed_load_type.is_some() {
294            warnings.push(format!(
295                "PSS/E load at bus {} id {id:?}: load type requires revision 35; dropped",
296                l.bus
297            ));
298        }
299        let modern_tail = if rev >= 35 {
300            let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
301            let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
302            let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
303            let raw_loadtype = typed_load_type.or_else(|| {
304                l.extras
305                    .get("psse_loadtype")
306                    .and_then(Value::as_str)
307                    .map(str::to_owned)
308            });
309            let loadtype =
310                sanitize_quoted(raw_loadtype.as_deref().unwrap_or(""), NAME_FORBIDDEN, ' ');
311            if matches!(loadtype, std::borrow::Cow::Owned(_)) {
312                sanitized_quoted += 1;
313            }
314            format!(
315                ", {}, {}, {flagstatus}, '{loadtype}'",
316                num(pdgen),
317                num(qdgen)
318            )
319        } else if modern {
320            let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
321            let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
322            let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
323            format!(", {}, {}, {flagstatus}", num(pdgen), num(qdgen))
324        } else {
325            String::new()
326        };
327        let _ = writeln!(
328            s,
329            "{}, '{id}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {owner}, {scal}, {intrpt}{modern_tail}",
330            l.bus,
331            i32::from(l.in_service),
332            area,
333            zone,
334            num(pl),
335            num(ql),
336            num(ip),
337            num(iq),
338            num(yp),
339            num(yq)
340        );
341    }
342    let _ = writeln!(s, "0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA");
343
344    // Fixed shunts here; switched shunts (control = Some) go in their own section.
345    let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
346    for sh in net.shunts.iter().filter(|s| s.control.is_none()) {
347        let id = quoted_device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_quoted);
348        let _ = writeln!(
349            s,
350            "{}, '{id}', {}, {}, {}",
351            sh.bus,
352            i32::from(sh.in_service),
353            num(sh.g),
354            num(sh.b)
355        );
356    }
357    let _ = writeln!(s, "0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA");
358
359    let mut gen_ids: BTreeMap<BusId, u32> = BTreeMap::new();
360    for g in &net.generators {
361        let id = positional_id(g.bus, &mut gen_ids);
362        // IREG (field 7): the remote regulated bus, or 0 for own-terminal control.
363        let ireg = g.regulated_bus.map_or(0, |b| b.0);
364        // v35 inserts NREG after IREG and BASLOD after PB; v34 keeps the v33 layout.
365        let (nreg, baslod) = if rev >= 35 { (" 0,", " 0,") } else { ("", "") };
366        let _ = writeln!(
367            s,
368            "{}, '{id}', {}, {}, {}, {}, {}, {},{nreg} {}, 0, 1, 0, 0, 1, {}, 100, {}, {},{baslod} 1, 1",
369            g.bus,
370            num(g.pg),
371            num(g.qg),
372            num(g.qmax),
373            num(g.qmin),
374            num(g.vg),
375            ireg,
376            num(g.mbase),
377            i32::from(g.in_service),
378            num(g.pmax),
379            num(g.pmin)
380        );
381    }
382    let _ = writeln!(s, "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA");
383
384    // Non-transformer branches here; transformers go in their own section.
385    // Parallel branches between the same bus pair get distinct circuit ids (PSS/E
386    // keys a branch on (I, J, CKT)); a captured source CKT wins.
387    let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
388    for (branch_index, br) in net
389        .branches
390        .iter()
391        .enumerate()
392        .filter(|(_, b)| !b.is_transformer())
393    {
394        let ckt = quoted_circuit_id(
395            br.extras.get("id").and_then(Value::as_str),
396            (br.from, br.to),
397            &mut branch_ids,
398            &mut sanitized_quoted,
399        );
400        let charging = br.terminal_charging();
401        let b_total = charging.total_b();
402        let b_mid = b_total / 2.0;
403        let bi = charging.b_fr - b_mid;
404        let bj = charging.b_to - b_mid;
405        if modern {
406            // v34+: a quoted line NAME at field 6, then twelve rating columns,
407            // pushing STAT to field 23 (the layout the reader expects at rev>=34).
408            // RATE4-RATE12 come from extra branch rating sets when present.
409            let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
410            let _ = writeln!(
411                s,
412                "{}, {}, '{ckt}', {}, {}, {}, '            ', {}, {}, {}, \
413                 {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
414                br.from,
415                br.to,
416                num(br.r),
417                num(br.x),
418                num(b_total),
419                num(br.rate_a),
420                num(br.rate_b),
421                num(br.rate_c),
422                num(extra_ratings[0]),
423                num(extra_ratings[1]),
424                num(extra_ratings[2]),
425                num(extra_ratings[3]),
426                num(extra_ratings[4]),
427                num(extra_ratings[5]),
428                num(extra_ratings[6]),
429                num(extra_ratings[7]),
430                num(extra_ratings[8]),
431                num(charging.g_fr),
432                num(bi),
433                num(charging.g_to),
434                num(bj),
435                i32::from(br.in_service)
436            );
437        } else {
438            let _ = writeln!(
439                s,
440                "{}, {}, '{ckt}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
441                br.from,
442                br.to,
443                num(br.r),
444                num(br.x),
445                num(b_total),
446                num(br.rate_a),
447                num(br.rate_b),
448                num(br.rate_c),
449                num(charging.g_fr),
450                num(bi),
451                num(charging.g_to),
452                num(bj),
453                i32::from(br.in_service)
454            );
455        }
456    }
457    let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
458
459    for (branch_index, br) in net
460        .branches
461        .iter()
462        .enumerate()
463        .filter(|(_, b)| b.is_transformer())
464    {
465        // 2-winding, 4-line record. CW=1 (turns ratio p.u.), CZ=1 (Z on system
466        // base). Record 1 carries the full owner block (O1..O4,F1..F4) and the
467        // VECGRP string: PSS/E v33 readers count a 2-winding transformer as a
468        // fixed 43-field record (21 + 3 + 17 + 2), so the owner padding matters.
469        // MAG1/MAG2 = the branch charging projected to one magnetizing
470        // admittance (CM = 1, so p.u. on the system base); a 2-winding
471        // transformer that carries line charging keeps the total.
472        let charging = br.terminal_charging();
473        let _ = writeln!(
474            s,
475            "{}, {}, 0, '1', 1, 1, 1, {}, {}, 2, '            ', {}, 1, 1, 0, 1, 0, 1, 0, 1, '            '",
476            br.from,
477            br.to,
478            num(charging.total_g()),
479            num(charging.total_b()),
480            i32::from(br.in_service)
481        );
482        // Winding-1 control columns (COD, CONT, RMA/RMI, VMA/VMI, NTP) come from
483        // the regulating-control data when present, else the fixed defaults.
484        let ctl = br.control.as_ref();
485        let sbase = ctl
486            .filter(|c| c.mva_base > 0.0)
487            .map_or(net.base_mva, |c| c.mva_base);
488        let cod = ctl.map_or(0, |c| mode_to_cod(c.mode));
489        let cont = ctl.and_then(|c| c.controlled_bus).map_or(0, |b| b.0);
490        let (rma, rmi, vma, vmi, ntp) = ctl.map_or((1.1, 0.9, 1.1, 0.9, 33), |c| {
491            (c.tap_max, c.tap_min, c.band_max, c.band_min, c.ntp)
492        });
493        let _ = writeln!(s, "{}, {}, {}", num(br.r), num(br.x), num(sbase));
494        if modern {
495            // v34+ winding line: twelve ratings (RATE4-RATE12 from extra rating
496            // sets), then COD, CONT, NODE, RMA, RMI, VMA, VMI, NTP, TAB, CR,
497            // CX, CNXA — COD at 15, matching the reader.
498            let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
499            let _ = writeln!(
500                s,
501                "{}, 0, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, \
502                 {cod}, {cont}, 0, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
503                num(br.effective_tap()),
504                num(br.shift),
505                num(br.rate_a),
506                num(br.rate_b),
507                num(br.rate_c),
508                num(extra_ratings[0]),
509                num(extra_ratings[1]),
510                num(extra_ratings[2]),
511                num(extra_ratings[3]),
512                num(extra_ratings[4]),
513                num(extra_ratings[5]),
514                num(extra_ratings[6]),
515                num(extra_ratings[7]),
516                num(extra_ratings[8]),
517                num(rma),
518                num(rmi),
519                num(vma),
520                num(vmi)
521            );
522        } else {
523            let _ = writeln!(
524                s,
525                "{}, 0, {}, {}, {}, {}, {cod}, {cont}, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
526                num(br.effective_tap()),
527                num(br.shift),
528                num(br.rate_a),
529                num(br.rate_b),
530                num(br.rate_c),
531                num(rma),
532                num(rmi),
533                num(vma),
534                num(vmi)
535            );
536        }
537        let _ = writeln!(s, "1.0, 0");
538    }
539
540    // 3-winding transformers: a 5-line record. CW=1, CZ=1, CM=1 (same conventions
541    // as the 2-winding record); line 2 carries the three pairwise impedances and
542    // the star-point voltage, lines 3-5 the per-winding tap/angle/ratings.
543    for t in &net.transformers_3w {
544        let raw_name = t.name.as_deref().unwrap_or("");
545        let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
546        if matches!(name, std::borrow::Cow::Owned(_)) {
547            sanitized_quoted += 1;
548        }
549        let _ = writeln!(
550            s,
551            "{}, {}, {}, '1', 1, 1, 1, {}, {}, 2, '{:<12}', {}, 1, 1, 0, 1, 0, 1, 0, 1, '            '",
552            t.windings[0].bus,
553            t.windings[1].bus,
554            t.windings[2].bus,
555            num(t.mag_g),
556            num(t.mag_b),
557            name,
558            i32::from(t.in_service)
559        );
560        // Line 2: the three pairwise (R, X) on the system base (CZ=1), each with
561        // its declared SBASE column, then the star voltage.
562        let [z12, z23, z31] = t.z;
563        let _ = writeln!(
564            s,
565            "{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}",
566            num(z12.r),
567            num(z12.x),
568            num(z12.base_mva),
569            num(z23.r),
570            num(z23.x),
571            num(z23.base_mva),
572            num(z31.r),
573            num(z31.x),
574            num(z31.base_mva),
575            num(t.star_vm),
576            num(t.star_va)
577        );
578        for w in &t.windings {
579            if modern {
580                // v34+ winding layout (twelve ratings, NODE after CONT); the
581                // Winding model carries three ratings, so RATE4-RATE12 are 0.
582                let _ = writeln!(
583                    s,
584                    "{}, {}, {}, {}, {}, {}, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, \
585                     0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
586                    num(w.tap),
587                    num(w.nominal_kv),
588                    num(w.shift),
589                    num(w.rate_a),
590                    num(w.rate_b),
591                    num(w.rate_c)
592                );
593            } else {
594                let _ = writeln!(
595                    s,
596                    "{}, {}, {}, {}, {}, {}, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
597                    num(w.tap),
598                    num(w.nominal_kv),
599                    num(w.shift),
600                    num(w.rate_a),
601                    num(w.rate_b),
602                    num(w.rate_c)
603                );
604            }
605        }
606    }
607    let _ = writeln!(s, "0 / END OF TRANSFORMER DATA, BEGIN AREA DATA");
608    for a in &net.areas {
609        let raw_name = a.name.as_deref().unwrap_or("");
610        let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
611        if matches!(name, std::borrow::Cow::Owned(_)) {
612            sanitized_quoted += 1;
613        }
614        let _ = writeln!(
615            s,
616            "{}, {}, {}, {}, '{:<12}'",
617            a.number,
618            a.slack_bus.map_or(0, |b| b.0),
619            num(a.net_interchange),
620            num(a.tolerance),
621            name
622        );
623    }
624
625    // Two-terminal DC lines occupy the first of the otherwise-empty sections:
626    // emit their 3-line records (if any) between the begin/end markers, then the
627    // remaining sections as bare terminators so the file parses as a complete case.
628    let _ = writeln!(s, "{}", EMPTY_SECTIONS[0]);
629    for (i, dc) in net.hvdc.iter().enumerate() {
630        let raw_name = dc_str(&dc.extras, "psse_dc_name").unwrap_or_else(|| format!("DC{}", i + 1));
631        let name = sanitize_quoted(&raw_name, NAME_FORBIDDEN, ' ');
632        if matches!(name, std::borrow::Cow::Owned(_)) {
633            sanitized_quoted += 1;
634        }
635        let name = format!("'{name}'");
636        let mdc = if dc.in_service {
637            dc_int(&dc.extras, "psse_dc_mdc").unwrap_or(1)
638        } else {
639            0
640        };
641        let rdc = dc_f64(&dc.extras, "psse_dc_rdc").unwrap_or(0.0);
642        let vschd = dc_f64(&dc.extras, "psse_dc_vschd").unwrap_or(0.0);
643        let l1_tail = dc_tail(
644            &dc.extras,
645            "psse_dc_control_tail",
646            "0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0",
647        );
648        let rect_tail = dc_tail(&dc.extras, "psse_dc_rectifier_tail", DEFAULT_CONVERTER_TAIL);
649        let inv_tail = dc_tail(&dc.extras, "psse_dc_inverter_tail", DEFAULT_CONVERTER_TAIL);
650        let _ = writeln!(
651            s,
652            "{name}, {mdc}, {}, {}, {}, {l1_tail}",
653            num(rdc),
654            num(dc.pf),
655            num(vschd)
656        );
657        let _ = writeln!(s, "{}, {rect_tail}", dc.from);
658        let _ = writeln!(s, "{}, {inv_tail}", dc.to);
659    }
660    // Sections up to and including the SWITCHED SHUNT begin marker.
661    for line in &EMPTY_SECTIONS[1..=9] {
662        let _ = writeln!(s, "{line}");
663    }
664    // Switched shunts: BINIT becomes the susceptance, the control record the rest.
665    // v35 inserts a quoted shunt ID at field 1 and NREG after SWREG, and its step
666    // blocks are (S, N, B) triples with a leading per-block status; v33/34 have
667    // neither and use (N, B) pairs. The writer must match the reader's layout at
668    // each revision or every later field is read columns off.
669    let mut sw_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
670    for sh in net.shunts.iter().filter(|s| s.control.is_some()) {
671        let Some(c) = sh.control.as_ref() else {
672            continue;
673        };
674        let swrem = c.control_bus.map_or(0, |b| b.0);
675        let mut blocks = String::new();
676        for blk in &c.blocks {
677            if rev >= 35 {
678                // The neutral model has no per-block status: every block is in
679                // service (S = 1).
680                let _ = write!(blocks, ", 1, {}, {}", blk.steps, num(blk.b));
681            } else {
682                let _ = write!(blocks, ", {}, {}", blk.steps, num(blk.b));
683            }
684        }
685        if rev >= 35 {
686            let id = quoted_device_id(&sh.extras, sh.bus, &mut sw_ids, &mut sanitized_quoted);
687            let _ = writeln!(
688                s,
689                "{}, '{id}', {}, 0, {}, {}, {}, {swrem}, 0, {}, '', {}{blocks}",
690                sh.bus,
691                mode_to_modsw(c.mode),
692                i32::from(sh.in_service),
693                num(c.vhigh),
694                num(c.vlow),
695                num(c.rmpct),
696                num(sh.b)
697            );
698        } else {
699            let _ = writeln!(
700                s,
701                "{}, {}, 0, {}, {}, {}, {swrem}, {}, '', {}{blocks}",
702                sh.bus,
703                mode_to_modsw(c.mode),
704                i32::from(sh.in_service),
705                num(c.vhigh),
706                num(c.vlow),
707                num(c.rmpct),
708                num(sh.b)
709            );
710        }
711    }
712    for line in &EMPTY_SECTIONS[10..] {
713        let _ = writeln!(s, "{line}");
714    }
715    let _ = writeln!(s, "Q");
716
717    if net
718        .hvdc
719        .iter()
720        .any(|d| !d.extras.contains_key("psse_dc_name"))
721    {
722        warnings.push(
723            "DC line converter detail (firing angles, converter transformer taps, reactive \
724             output) defaulted: PSS/E two-terminal DC is written from the power setpoint and \
725             line resistance only"
726                .into(),
727        );
728    }
729    if !net.storage.is_empty() {
730        warnings.push(format!(
731            "{} storage unit(s) dropped: PSS/E has no storage record",
732            net.storage.len()
733        ));
734    }
735    if net.generators.iter().any(|g| g.cost.is_some()) {
736        warnings.push("generator cost curves dropped: PSS/E .raw has no cost data".into());
737    }
738    if net.branches.iter().any(Branch::has_angle_limits) {
739        warnings.push(
740            "branch angle limits (angmin/angmax) dropped: PSS/E branch records carry none".into(),
741        );
742    }
743    let current_ratings = net
744        .branches
745        .iter()
746        .filter(|b| b.current_ratings.is_some())
747        .count();
748    if current_ratings > 0 {
749        warnings.push(format!(
750            "{current_ratings} branch current rating record(s) dropped: PSS/E branch ratings are MVA ratings"
751        ));
752    }
753    if !modern {
754        warn_psse_extra_branch_ratings_dropped(net, &mut warnings);
755    }
756    let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
757    if branch_solutions > 0 {
758        warnings.push(format!(
759            "{branch_solutions} branch solution value set(s) dropped: PSS/E RAW power flow result fields are not written"
760        ));
761    }
762    let transformer_terminal_shunts = net
763        .branches
764        .iter()
765        .filter(|b| {
766            b.is_transformer()
767                && b.charging
768                    .is_some_and(|c| c.g_to.abs() > f64::EPSILON || c.b_to.abs() > f64::EPSILON)
769        })
770        .count();
771    if transformer_terminal_shunts > 0 {
772        warnings.push(format!(
773            "{transformer_terminal_shunts} transformer terminal admittance record(s) collapsed to magnetizing admittance: PSS/E transformer records cannot preserve terminal side assignment"
774        ));
775    }
776    if net.generators.iter().any(Generator::has_caps) {
777        warnings.push(
778            "generator ramp/capability columns dropped: PSS/E .raw has no equivalent fields".into(),
779        );
780    }
781    if nonfinite {
782        warnings.push("non-finite values written as ±1e10 sentinels (PSS/E has no Inf/NaN)".into());
783    }
784    if sanitized_quoted > 0 {
785        warnings.push(format!(
786            "{sanitized_quoted} quoted PSS/E field(s) contained a quote or '/' that would \
787             corrupt a record; replaced with spaces"
788        ));
789    }
790
791    Conversion { text: s, warnings }
792}
793
794/// MATPOWER/neutral bus kind → PSS/E bus type code (IDE).
795fn ide(kind: BusType) -> u8 {
796    kind as u8 // 1=PQ, 2=PV, 3=ref/swing, 4=isolated — same codes
797}
798
799/// The circuit id for an element: its sanitized `extras["id"]` when present and
800/// still free on this bus, else the lowest positional id still free, so parallel
801/// devices stay distinct and the PSS/E `(bus, id)` uniqueness rule holds even
802/// when source ids collide before or after sanitation. `used` tracks the ids
803/// already emitted per bus.
804fn quoted_device_id(
805    extras: &Extras,
806    bus: BusId,
807    used: &mut BTreeMap<BusId, BTreeSet<String>>,
808    sanitized_quoted: &mut usize,
809) -> String {
810    quoted_circuit_id(
811        extras.get("id").and_then(Value::as_str),
812        bus,
813        used,
814        sanitized_quoted,
815    )
816}
817
818fn quoted_circuit_id<K: Ord + Clone>(
819    preferred: Option<&str>,
820    key: K,
821    used: &mut BTreeMap<K, BTreeSet<String>>,
822    sanitized_quoted: &mut usize,
823) -> String {
824    let sanitized = preferred.map(|id| {
825        let cleaned = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
826        if matches!(cleaned, std::borrow::Cow::Owned(_)) {
827            *sanitized_quoted += 1;
828        }
829        cleaned.into_owned()
830    });
831    super::allocate_circuit_id(sanitized.as_deref(), key, used)
832}
833
834/// The next positional circuit id for `bus` (for elements with no extras to carry
835/// a captured id, such as generators).
836fn positional_id(bus: BusId, counters: &mut BTreeMap<BusId, u32>) -> String {
837    let n = counters.entry(bus).or_insert(0);
838    *n += 1;
839    n.to_string()
840}
841
842/// Converter-line tail (everything after the AC terminal bus) for a synthesized
843/// two-terminal DC record: NBR/NBI bridges, firing-angle limits, converter
844/// transformer R/X and tap data, and the metered-end id. PSS/E-sourced lines
845/// replay their own tail; these defaults serve a cross-format source.
846const DEFAULT_CONVERTER_TAIL: &str =
847    "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";
848
849const EMPTY_SECTIONS: [&str; 13] = [
850    "0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA",
851    "0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA",
852    "0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA",
853    "0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA",
854    "0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA",
855    "0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA",
856    "0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA",
857    "0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA",
858    "0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA",
859    "0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA",
860    "0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA",
861    "0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA",
862    "0 / END OF INDUCTION MACHINE DATA",
863];
864
865// ---- Reader -----------------------------------------------------------------
866
867/// Parse a PSS/E `.raw` (revisions 33-35) into a [`BalancedNetwork`]. Reads bus/load/
868/// fixed-shunt/generator/branch/2- and 3-winding transformer; skips the advanced
869/// sections.
870pub fn parse_psse(content: &str) -> Result<BalancedNetwork> {
871    let mut warnings = Vec::new();
872    parse_psse_source(Arc::new(content.to_owned()), None, &mut warnings)
873}
874
875/// The PSS/E revision declared in a retained `.raw` header (field 3, `REV`), or
876/// 33 when it is absent or unparseable. The format hub uses it to decide whether
877/// a same-format write can echo the source bytes or must re-emit at a different
878/// revision.
879pub(crate) fn header_rev(source: &str) -> u32 {
880    let Some(header) = source
881        .lines()
882        .map(str::trim)
883        .find(|line| !line.is_empty() && !is_comment(line))
884    else {
885        return 33;
886    };
887    fields(header)
888        .get(2)
889        .and_then(|f| f.parse::<f64>().ok())
890        .filter(|v| v.is_finite() && *v >= 0.0)
891        .map_or(33, |v| v as u32)
892}
893
894/// Owned-source entry used by the format hub: parse by borrowing `source`, then
895/// move the buffer into the retained source (no copy). `name_hint` (e.g. a file
896/// stem) names the network when the title line is blank.
897// A flat reader: header parse plus one match arm per section. Splitting it would
898// add indirection without clarity.
899#[expect(clippy::too_many_lines)]
900pub(crate) fn parse_psse_source(
901    source: Arc<String>,
902    name_hint: Option<&str>,
903    warnings: &mut Vec<String>,
904) -> Result<BalancedNetwork> {
905    let content: &str = &source;
906    let mut lines = content.lines();
907
908    // Header line 1: IC, SBASE, REV, ...
909    let header = lines
910        .by_ref()
911        .find(|line| {
912            let line = line.trim();
913            !line.is_empty() && !is_comment(line)
914        })
915        .ok_or_else(|| Error::FormatRead {
916            format: FMT,
917            message: "empty file".into(),
918        })?;
919    let header_fields = fields(header);
920    let base_mva = header_fields
921        .get(1)
922        .and_then(|f| f.parse::<f64>().ok())
923        .ok_or_else(|| Error::FormatRead {
924            format: FMT,
925            message: "missing SBASE in header".into(),
926        })?;
927    let raw_rev = header_fields
928        .get(2)
929        .and_then(|f| f.parse::<f64>().ok())
930        .filter(|v| v.is_finite() && *v >= 0.0)
931        .map_or(33, |v| v as u32);
932    // BASFRQ is the sixth header field (IC, SBASE, REV, XFRRAT, NXFRAT, BASFRQ);
933    // older revisions that carry only `SBASE, title` lack it, so default it.
934    let base_frequency = header_fields
935        .get(5)
936        .and_then(|f| f.parse::<f64>().ok())
937        .filter(|v| v.is_finite() && *v > 0.0)
938        .unwrap_or(crate::network::DEFAULT_BASE_FREQUENCY);
939    // Line 2 is the case title; we write the network name there, so read it back.
940    let title = lines.next().unwrap_or("").trim();
941    let name = if title.is_empty() {
942        name_hint.unwrap_or("case").to_string()
943    } else {
944        title.to_string()
945    };
946    lines.next(); // line 3: second comment
947
948    let mut buses = Vec::new();
949    let mut loads = Vec::new();
950    let mut shunts = Vec::new();
951    let mut generators = Vec::new();
952    let mut branches = Vec::new();
953    let mut transformers_3w = Vec::new();
954    let mut hvdc = Vec::new();
955    let mut areas = Vec::new();
956    let mut solver = SolverParams::default();
957    let mut bus_base_kv: BTreeMap<BusId, f64> = BTreeMap::new();
958    let mut unmodeled_sections: BTreeMap<String, usize> = BTreeMap::new();
959
960    // Sections appear in fixed order, each ended by a record whose first field is
961    // `0`. We read the ones we model and treat the rest as skipped.
962    let mut section = Section::Bus;
963    let mut saw_bus_marker = false;
964    let mut skipped_section_name: Option<String> = None;
965    let mut lines = lines.peekable();
966    while let Some(raw) = lines.next() {
967        let line = raw.trim();
968        if line.is_empty() {
969            continue;
970        }
971        if is_comment(line) {
972            continue;
973        }
974        if line == "Q" {
975            break;
976        }
977        if is_terminator(line) {
978            // The terminator names the section that begins next ("…, BEGIN
979            // SWITCHED SHUNT DATA"); read that rather than counting, so the many
980            // unmodeled sections between transformers and switched shunts don't
981            // throw off the position.
982            let next_section = section_after_marker(line);
983            skipped_section_name =
984                introduced_section_name(line).filter(|_| matches!(next_section, Section::Skip));
985            section = next_section;
986            saw_bus_marker |= matches!(section, Section::Bus);
987            continue;
988        }
989        let f = fields(line);
990        match section {
991            Section::Bus if !saw_bus_marker && buses.is_empty() && is_system_wide_record(&f) => {
992                // The v34+ system-wide block precedes the bus data; capture its
993                // solver keyword lines (this is the first one that triggered).
994                section = Section::SystemWide;
995                parse_solver_line(&f, &mut solver);
996            }
997            Section::Bus => {
998                let bus = read_bus(&f)?;
999                bus_base_kv.insert(bus.id, bus.base_kv);
1000                buses.push(bus);
1001            }
1002            Section::Load => loads.push(read_load(&f, raw_rev, warnings)?),
1003            Section::FixedShunt => shunts.push(read_shunt(&f)?),
1004            Section::SwitchedShunt => shunts.push(read_switched_shunt(&f, raw_rev)?),
1005            Section::Generator => generators.push(read_gen(&f, raw_rev)?),
1006            Section::Branch => branches.push(read_branch(&f, raw_rev)?),
1007            Section::Transformer => {
1008                // 2-winding = 4 lines (K field == 0); 3-winding = 5 lines.
1009                // int_at parses through f64: v34/35 exporters write K in float
1010                // form ("0.00"), and an i64 parse would misclassify the record
1011                // as 3-winding and desynchronize the section.
1012                let two_winding = int_at(&f, 2, 0)? == 0;
1013                let l2 = next_continuation_line(
1014                    &mut lines,
1015                    "transformer",
1016                    "transformer impedance line",
1017                )?;
1018                let l3 = next_continuation_line(&mut lines, "transformer", "winding data line 1")?;
1019                let l4 = next_continuation_line(&mut lines, "transformer", "winding data line 2")?;
1020                if two_winding {
1021                    // MAG2 maps to the branch charging b only at CM = 1; a CM != 1
1022                    // record states magnetizing data in units this reader does not
1023                    // convert, so read_transformer drops it. Name the loss.
1024                    if int_at(&f, 6, 1)? != 1 && num_at(&f, 8, 0.0)? != 0.0 {
1025                        warnings.push(format!(
1026                            "transformer {}-{}: magnetizing data with CM != 1 dropped \
1027                             (only CM = 1 p.u. susceptance is read as branch charging)",
1028                            f.first().map_or("?", String::as_str),
1029                            f.get(1).map_or("?", String::as_str),
1030                        ));
1031                    }
1032                    branches.push(read_transformer(
1033                        &f,
1034                        &fields(l2),
1035                        &fields(l3),
1036                        &fields(l4),
1037                        raw_rev,
1038                        base_mva,
1039                        &bus_base_kv,
1040                        warnings,
1041                    )?);
1042                } else {
1043                    let l5 =
1044                        next_continuation_line(&mut lines, "transformer", "winding data line 3")?;
1045                    transformers_3w.push(read_transformer_3w(
1046                        &f,
1047                        &fields(l2),
1048                        &fields(l3),
1049                        &fields(l4),
1050                        &fields(l5),
1051                        base_mva,
1052                        &bus_base_kv,
1053                        warnings,
1054                    )?);
1055                }
1056            }
1057            Section::TwoTerminalDc => {
1058                // 3-line record: control line, then the rectifier and inverter
1059                // converter lines whose first field is the AC terminal bus.
1060                let rectifier =
1061                    next_continuation_line(&mut lines, "two-terminal DC", "rectifier line")?;
1062                let inverter =
1063                    next_continuation_line(&mut lines, "two-terminal DC", "inverter line")?;
1064                hvdc.push(read_dc_line(&f, &fields(rectifier), &fields(inverter))?);
1065            }
1066            Section::Area => areas.push(read_area(&f)?),
1067            Section::SystemWide => parse_solver_line(&f, &mut solver),
1068            Section::Skip => {
1069                if let Some(name) = skipped_section_name.as_ref() {
1070                    *unmodeled_sections.entry(name.clone()).or_default() += 1;
1071                }
1072            }
1073        }
1074    }
1075
1076    warn_unmodeled_sections(unmodeled_sections, warnings);
1077
1078    let mut net = BalancedNetwork {
1079        name,
1080        base_mva,
1081        base_frequency,
1082        geo: None,
1083        buses,
1084        loads,
1085        shunts,
1086        branches,
1087        switches: Vec::new(),
1088        generators,
1089        storage: Vec::new(),
1090        hvdc,
1091        transformers_3w,
1092        areas,
1093        solver: (!solver.is_empty()).then_some(solver),
1094        source_format: SourceFormat::Psse,
1095        source: Some(source),
1096    };
1097    drop_stale_control_pointers(&mut net, warnings);
1098    net.check_references(FMT)?;
1099    Ok(net)
1100}
1101
1102#[derive(Clone, Copy)]
1103enum Section {
1104    Bus,
1105    Load,
1106    FixedShunt,
1107    SwitchedShunt,
1108    Generator,
1109    Branch,
1110    Transformer,
1111    TwoTerminalDc,
1112    Area,
1113    SystemWide,
1114    Skip,
1115}
1116
1117/// The section a terminator introduces. Sections we don't model map to
1118/// [`Section::Skip`]. Case-insensitive on the marker text, so the number of
1119/// skipped sections between the modeled ones doesn't matter.
1120fn section_after_marker(line: &str) -> Section {
1121    match introduced_section_name(line).as_deref() {
1122        Some("BUS") => Section::Bus,
1123        Some("LOAD") => Section::Load,
1124        Some("FIXED SHUNT") => Section::FixedShunt,
1125        Some("SWITCHED SHUNT") => Section::SwitchedShunt,
1126        Some("GENERATOR" | "GEN") => Section::Generator,
1127        Some("BRANCH") => Section::Branch,
1128        Some("TRANSFORMER") => Section::Transformer,
1129        Some("TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC") => {
1130            Section::TwoTerminalDc
1131        }
1132        Some("AREA" | "AREA INTERCHANGE") => Section::Area,
1133        _ => Section::Skip,
1134    }
1135}
1136
1137/// A record line's first field is `0` (the section terminator).
1138fn is_terminator(line: &str) -> bool {
1139    fields(line).first().map(String::as_str) == Some("0")
1140}
1141
1142fn next_continuation_line<'a>(
1143    lines: &mut std::iter::Peekable<std::str::Lines<'a>>,
1144    record: &str,
1145    expected: &str,
1146) -> Result<&'a str> {
1147    for line in lines.by_ref().map(str::trim) {
1148        if line.is_empty() || is_comment(line) {
1149            continue;
1150        }
1151        if line.eq_ignore_ascii_case("q") || is_section_marker(line) || is_bare_terminator(line) {
1152            return Err(Error::FormatRead {
1153                format: FMT,
1154                message: format!(
1155                    "PSS/E {record} record ended before {expected}: found section terminator `{line}`"
1156                ),
1157            });
1158        }
1159        return Ok(line);
1160    }
1161    Err(Error::FormatRead {
1162        format: FMT,
1163        message: format!("PSS/E {record} record ended before {expected}"),
1164    })
1165}
1166
1167fn is_bare_terminator(line: &str) -> bool {
1168    let f = fields(line);
1169    f.len() == 1 && f.first().map(String::as_str) == Some("0")
1170}
1171
1172fn transformer_basis_codes(f: &[String]) -> Result<(i64, i64)> {
1173    let cw = num_at(f, 4, 1.0)?;
1174    if cw.fract() != 0.0 {
1175        return Err(bad_field(4, f.get(4).map_or("", String::as_str)));
1176    }
1177    let cz = num_at(f, 5, 1.0)?;
1178    if cz.fract() != 0.0 {
1179        return Err(bad_field(5, f.get(5).map_or("", String::as_str)));
1180    }
1181    #[allow(clippy::cast_possible_truncation)]
1182    Ok((cw as i64, cz as i64))
1183}
1184
1185fn transformer_label(f: &[String]) -> String {
1186    let i = f.first().map_or("?", String::as_str);
1187    let j = f.get(1).map_or("?", String::as_str);
1188    let k = f.get(2).map_or("?", String::as_str);
1189    let id = f.get(3).map_or("", String::as_str);
1190    format!("{i}-{j}-{k} id {id:?}")
1191}
1192
1193#[expect(clippy::too_many_arguments)]
1194fn convert_transformer_impedance(
1195    r: f64,
1196    x: f64,
1197    sbase: f64,
1198    system_base: f64,
1199    cz: i64,
1200    label: &str,
1201    pair: &str,
1202    warnings: &mut Vec<String>,
1203) -> (f64, f64) {
1204    let base_ok = sbase.is_finite() && sbase > 0.0;
1205    match cz {
1206        1 => (r, x),
1207        2 => {
1208            if base_ok {
1209                let scale = system_base / sbase;
1210                (r * scale, x * scale)
1211            } else {
1212                warnings.push(format!(
1213                    "PSS/E transformer {label} pair {pair}: CZ=2 impedance has invalid SBASE {sbase}; read as system-base p.u."
1214                ));
1215                (r, x)
1216            }
1217        }
1218        3 => {
1219            if !base_ok {
1220                warnings.push(format!(
1221                    "PSS/E transformer {label} pair {pair}: CZ=3 impedance has invalid SBASE {sbase}; read as system-base p.u."
1222                ));
1223                return (r, x);
1224            }
1225            let r_pair = (r / 1_000_000.0) / sbase;
1226            let z_pair = x.abs();
1227            let x_pair = (z_pair.mul_add(z_pair, -(r_pair * r_pair)))
1228                .max(0.0)
1229                .sqrt()
1230                .copysign(x);
1231            let scale = system_base / sbase;
1232            (r_pair * scale, x_pair * scale)
1233        }
1234        other => {
1235            warnings.push(format!(
1236                "PSS/E transformer {label} pair {pair}: unsupported CZ={other}; read impedance as system-base p.u."
1237            ));
1238            (r, x)
1239        }
1240    }
1241}
1242
1243fn default_windv(cw: i64, bus: BusId, bus_base_kv: &BTreeMap<BusId, f64>) -> f64 {
1244    if cw == 2 {
1245        bus_base_kv
1246            .get(&bus)
1247            .copied()
1248            .filter(|v| *v > 0.0)
1249            .unwrap_or(1.0)
1250    } else {
1251        1.0
1252    }
1253}
1254
1255fn winding_ratio(
1256    w: &[String],
1257    bus: BusId,
1258    cw: i64,
1259    bus_base_kv: &BTreeMap<BusId, f64>,
1260    label: &str,
1261    winding: &str,
1262    warnings: &mut Vec<String>,
1263) -> Result<f64> {
1264    let windv = num_at(w, 0, default_windv(cw, bus, bus_base_kv))?;
1265    let nomv = num_at(w, 1, 0.0)?;
1266    let base_kv = bus_base_kv.get(&bus).copied().unwrap_or(0.0);
1267    let needs_base = matches!(cw, 2 | 3);
1268    if needs_base && !(base_kv.is_finite() && base_kv > 0.0) {
1269        warnings.push(format!(
1270            "PSS/E transformer {label} {winding}: CW={cw} needs a positive bus base kV for bus {bus}; read WINDV as a p.u. tap ratio"
1271        ));
1272        return Ok(windv);
1273    }
1274    match cw {
1275        1 => Ok(windv),
1276        2 => Ok(windv / base_kv),
1277        3 => {
1278            let nominal = if nomv.is_finite() && nomv > 0.0 {
1279                nomv
1280            } else {
1281                base_kv
1282            };
1283            Ok(windv * nominal / base_kv)
1284        }
1285        other => {
1286            warnings.push(format!(
1287                "PSS/E transformer {label} {winding}: unsupported CW={other}; read WINDV as a p.u. tap ratio"
1288            ));
1289            Ok(windv)
1290        }
1291    }
1292}
1293
1294#[expect(clippy::too_many_arguments)]
1295fn two_winding_tap(
1296    l1: &[String],
1297    l3: &[String],
1298    l4: &[String],
1299    from: BusId,
1300    to: BusId,
1301    cw: i64,
1302    bus_base_kv: &BTreeMap<BusId, f64>,
1303    warnings: &mut Vec<String>,
1304) -> Result<f64> {
1305    let label = transformer_label(l1);
1306    let ratio1 = winding_ratio(l3, from, cw, bus_base_kv, &label, "winding 1", warnings)?;
1307    let ratio2 = winding_ratio(l4, to, cw, bus_base_kv, &label, "winding 2", warnings)?;
1308    if ratio2.abs() <= f64::EPSILON {
1309        warnings.push(format!(
1310            "PSS/E transformer {label}: winding 2 ratio is zero; used winding 1 ratio as the branch tap"
1311        ));
1312        Ok(ratio1)
1313    } else {
1314        Ok(ratio1 / ratio2)
1315    }
1316}
1317
1318/// A terminator that also delimits a named section (`... END OF X DATA, BEGIN Y
1319/// DATA`), as opposed to the case header (whose first field is also `0`).
1320fn is_section_marker(line: &str) -> bool {
1321    if !is_terminator(line) {
1322        return false;
1323    }
1324    let u = line.to_ascii_uppercase();
1325    u.contains("END OF") || u.contains("BEGIN ") || u.contains("START OF ")
1326}
1327
1328/// The upper-cased section name a `BEGIN <name> DATA` or `START OF <name> DATA`
1329/// marker introduces.
1330fn introduced_section_name(line: &str) -> Option<String> {
1331    let u = line.to_ascii_uppercase();
1332    let (start, prefix_len) = u
1333        .find("BEGIN ")
1334        .map(|idx| (idx, "BEGIN ".len()))
1335        .or_else(|| u.find("START OF ").map(|idx| (idx, "START OF ".len())))?;
1336    let start = start + prefix_len;
1337    let rest = &u[start..];
1338    let end = rest.find(" DATA")?;
1339    Some(rest[..end].trim().to_string())
1340}
1341
1342/// Warn about non-empty PSS/E sections the reader does not model (VSC and
1343/// multi-terminal DC, impedance correction, substation/node, multi-section line,
1344/// induction machine, FACTS, GNE, owner/zone, ...). Counts come from the parser
1345/// pass itself, so bare `0` terminators and malformed continuation boundaries are
1346/// classified the same way as the records that get skipped.
1347fn warn_unmodeled_sections(totals: BTreeMap<String, usize>, warnings: &mut Vec<String>) {
1348    for (name, rows) in totals {
1349        warnings.push(format!(
1350            "PSS/E {name} section ({rows} record line(s)) is not modeled: preserved only in a \
1351             same-format .raw echo, dropped on any other write"
1352        ));
1353    }
1354}
1355
1356fn drop_stale_control_pointers(net: &mut BalancedNetwork, warnings: &mut Vec<String>) {
1357    let bus_ids: BTreeSet<BusId> = net.buses.iter().map(|b| b.id).collect();
1358    let missing = |bus: BusId| !bus_ids.contains(&bus);
1359
1360    for (idx, g) in net.generators.iter_mut().enumerate() {
1361        let Some(bus) = g.regulated_bus.filter(|b| missing(*b)) else {
1362            continue;
1363        };
1364        warnings.push(format!(
1365            "PSS/E GENERATOR DATA record {} at bus {}: IREG references missing bus id {}; dropped remote voltage control",
1366            idx + 1,
1367            g.bus,
1368            bus
1369        ));
1370        g.regulated_bus = None;
1371    }
1372
1373    for (idx, br) in net.branches.iter_mut().enumerate() {
1374        let Some(control) = br.control.as_mut() else {
1375            continue;
1376        };
1377        let Some(bus) = control.controlled_bus.filter(|b| missing(*b)) else {
1378            continue;
1379        };
1380        warnings.push(format!(
1381            "PSS/E TRANSFORMER DATA record {} ({}-{}): CONT references missing bus id {}; dropped transformer control pointer",
1382            idx + 1,
1383            br.from,
1384            br.to,
1385            bus
1386        ));
1387        control.controlled_bus = None;
1388    }
1389
1390    for (idx, shunt) in net.shunts.iter_mut().enumerate() {
1391        let Some(control) = shunt.control.as_mut() else {
1392            continue;
1393        };
1394        let Some(bus) = control.control_bus.filter(|b| missing(*b)) else {
1395            continue;
1396        };
1397        warnings.push(format!(
1398            "PSS/E SWITCHED SHUNT DATA record {} at bus {}: SWREM references missing bus id {}; dropped switched shunt control pointer",
1399            idx + 1,
1400            shunt.bus,
1401            bus
1402        ));
1403        control.control_bus = None;
1404    }
1405
1406    for (idx, area) in net.areas.iter_mut().enumerate() {
1407        let Some(bus) = area.slack_bus.filter(|b| missing(*b)) else {
1408            continue;
1409        };
1410        warnings.push(format!(
1411            "PSS/E AREA DATA record {} area {}: ISW references missing bus id {}; dropped area swing pointer",
1412            idx + 1,
1413            area.number,
1414            bus
1415        ));
1416        area.slack_bus = None;
1417    }
1418}
1419
1420fn is_comment(line: &str) -> bool {
1421    line.starts_with("@!") || line.starts_with('@')
1422}
1423
1424fn is_system_wide_record(f: &[String]) -> bool {
1425    matches!(
1426        f.first().map(|s| s.to_ascii_uppercase()),
1427        Some(first) if matches!(first.as_str(), "GENERAL" | "RATING" | "NEWTON" | "SOLVER")
1428    )
1429}
1430
1431/// Parse a v34+ system-wide keyword line (`GENERAL`/`NEWTON`/`SOLVER`, each a
1432/// keyword then `KEY=VALUE` tokens) into the solver record. Unrecognized
1433/// keywords (e.g. `RATING`) and keys are ignored.
1434fn parse_solver_line(f: &[String], solver: &mut SolverParams) {
1435    let Some(keyword) = f.first().map(|s| s.to_ascii_uppercase()) else {
1436        return;
1437    };
1438    for tok in &f[1..] {
1439        let Some((key, val)) = tok.split_once('=') else {
1440            continue;
1441        };
1442        let (key, val) = (key.trim().to_ascii_uppercase(), val.trim());
1443        match (keyword.as_str(), key.as_str()) {
1444            ("GENERAL", "THRSHZ") => solver.zero_impedance_threshold = val.parse().ok(),
1445            ("NEWTON", "TOLN") => solver.newton_tolerance = val.parse().ok(),
1446            ("NEWTON", "ITMXN") => solver.max_iterations = val.parse().ok(),
1447            ("SOLVER", "ACTAPS") => solver.adjust_taps = Some(parse_enable(val)),
1448            ("SOLVER", "AREAIN") => solver.adjust_area_interchange = Some(parse_enable(val)),
1449            ("SOLVER", "PHSHFT") => solver.adjust_phase_shift = Some(parse_enable(val)),
1450            ("SOLVER", "DCTAPS") => solver.adjust_dc_taps = Some(parse_enable(val)),
1451            ("SOLVER", "SWSHNT") => solver.adjust_switched_shunt = Some(parse_enable(val)),
1452            _ => {}
1453        }
1454    }
1455}
1456
1457/// A `SOLVER` adjustment flag: numeric → nonzero is enabled; a keyword is enabled
1458/// unless it reads as off.
1459fn parse_enable(val: &str) -> bool {
1460    val.parse::<f64>().map_or_else(
1461        |_| !matches!(val.to_ascii_uppercase().as_str(), "DISABLED" | "OFF" | "NO"),
1462        |n| n != 0.0,
1463    )
1464}
1465
1466/// Return the record body before an inline `/` comment, but only when the slash
1467/// is outside a single-quoted PSS/E field.
1468fn strip_inline_comment(line: &str) -> &str {
1469    let mut quoted = false;
1470    for (i, c) in line.char_indices() {
1471        match c {
1472            '\'' => quoted = !quoted,
1473            '/' if !quoted => return &line[..i],
1474            _ => {}
1475        }
1476    }
1477    line
1478}
1479
1480/// Split a PSS/E record into trimmed, unquoted fields, dropping a trailing
1481/// `/comment`. Comma-delimited records keep empty fields (column position is
1482/// significant — a blank quoted name must not shift later columns); records with
1483/// no commas fall back to whitespace splitting.
1484fn fields(line: &str) -> Vec<String> {
1485    let code = strip_inline_comment(line);
1486    let comma_delimited = code.contains(',');
1487    // Size both buffers up front and keep `cur`'s capacity across fields.
1488    // Taking `cur` handed the buffer away, so every field regrew from empty
1489    // and then paid a second allocation to trim: two allocations and a
1490    // realloc chain per field, which dominated the cost of reading a `.raw`
1491    // (~950 allocations per KB of input).
1492    let mut out = Vec::with_capacity(if comma_delimited {
1493        code.bytes().filter(|b| *b == b',').count() + 1
1494    } else {
1495        8
1496    });
1497    let mut cur = String::with_capacity(32);
1498    let mut quoted = false;
1499    for c in code.chars() {
1500        match c {
1501            '\'' => quoted = !quoted,
1502            ',' if !quoted && comma_delimited => {
1503                out.push(cur.trim().to_owned());
1504                cur.clear();
1505            }
1506            c if c.is_whitespace() && !quoted && !comma_delimited => {
1507                if !cur.is_empty() {
1508                    out.push(cur.clone());
1509                    cur.clear();
1510                }
1511            }
1512            c => cur.push(c),
1513        }
1514    }
1515    let last = cur.trim().to_owned();
1516    if comma_delimited || !last.is_empty() {
1517        out.push(last);
1518    }
1519    out
1520}
1521
1522fn bad_field(i: usize, tok: &str) -> Error {
1523    Error::FormatRead {
1524        format: FMT,
1525        message: format!("field {i} {tok:?} is not a number"),
1526    }
1527}
1528
1529/// Field `i` as f64. Absent or empty → `default` (a genuinely optional column).
1530/// Present but unparseable → a hard error: a malformed number must not silently
1531/// become a plausible default (e.g. a garbled reactance collapsing to 0.0, which
1532/// would drop the branch from every matrix) and corrupt the result.
1533fn num_at(f: &[String], i: usize, default: f64) -> Result<f64> {
1534    match f.get(i).map(String::as_str) {
1535        None | Some("") => Ok(default),
1536        Some(s) => s.parse().map_err(|_| bad_field(i, s)),
1537    }
1538}
1539/// Field `i` as a bus id (parsed as f64 then truncated, the PSS/E convention).
1540fn id_at(f: &[String], i: usize, default: usize) -> Result<usize> {
1541    match f.get(i).map(String::as_str) {
1542        None | Some("") => Ok(default),
1543        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1544        Some(s) => s
1545            .parse::<f64>()
1546            .map(|v| v as usize)
1547            .map_err(|_| bad_field(i, s)),
1548    }
1549}
1550/// Field `i` as a status flag (nonzero = in service).
1551fn on_at(f: &[String], i: usize, default: bool) -> Result<bool> {
1552    match f.get(i).map(String::as_str) {
1553        None | Some("") => Ok(default),
1554        Some(s) => s
1555            .parse::<f64>()
1556            .map(|v| v != 0.0)
1557            .map_err(|_| bad_field(i, s)),
1558    }
1559}
1560/// Field `i` as an integer code (bus type, etc.).
1561fn int_at(f: &[String], i: usize, default: i64) -> Result<i64> {
1562    match f.get(i).map(String::as_str) {
1563        None | Some("") => Ok(default),
1564        // v34/35 exporters write integer fields in float form (`0.00` for `0`), so
1565        // parse through f64 and truncate, the way `id_at` already does.
1566        #[allow(clippy::cast_possible_truncation)]
1567        Some(s) => s
1568            .parse::<f64>()
1569            .map(|v| v as i64)
1570            .map_err(|_| bad_field(i, s)),
1571    }
1572}
1573
1574fn bustype(code: i64) -> BusType {
1575    match code {
1576        2 => BusType::Pv,
1577        3 => BusType::Ref,
1578        4 => BusType::Isolated,
1579        _ => BusType::Pq,
1580    }
1581}
1582
1583// The EVHI/EVLO equality below is an exact compare on purpose: the emergency
1584// band is typed only when its token differs from the normal-band token.
1585#[allow(clippy::float_cmp)]
1586fn read_bus(f: &[String]) -> Result<Bus> {
1587    // I, NAME, BASKV, IDE, AREA, ZONE, OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO
1588    let id = f
1589        .first()
1590        .and_then(|x| x.parse::<f64>().ok())
1591        .ok_or_else(|| Error::FormatRead {
1592            format: FMT,
1593            message: "bus record missing numeric id (field I)".into(),
1594        })? as usize;
1595    let name = f
1596        .get(1)
1597        .filter(|n| !n.is_empty())
1598        .map(|n| n.trim().to_string());
1599    let vmax = num_at(f, 9, 1.1)?;
1600    let vmin = num_at(f, 10, 0.9)?;
1601    // EVHI/EVLO (v31+); default to the normal band when absent. Keep them typed
1602    // only when they actually differ, so the common equal-band case stays `None`.
1603    let evhi = num_at(f, 11, vmax)?;
1604    let evlo = num_at(f, 12, vmin)?;
1605    Ok(Bus {
1606        id: BusId(id),
1607        kind: bustype(int_at(f, 3, 1)?),
1608        vm: num_at(f, 7, 1.0)?,
1609        va: num_at(f, 8, 0.0)?,
1610        base_kv: num_at(f, 2, 0.0)?,
1611        vmax,
1612        vmin,
1613        evhi: (evhi != vmax).then_some(evhi),
1614        evlo: (evlo != vmin).then_some(evlo),
1615        area: id_at(f, 4, 0)?,
1616        zone: id_at(f, 5, 0)?,
1617        name,
1618        uid: None,
1619        location: None,
1620        extras: Extras::new(),
1621    })
1622}
1623
1624/// Capture an element's circuit id (field `i`, a quoted 1-2 char string) into its
1625/// extras under `"id"`, so a round trip keeps the id and parallel devices on a bus
1626/// stay distinguishable.
1627fn device_extras(f: &[String], i: usize) -> Extras {
1628    let mut extras = Extras::new();
1629    if let Some(id) = f.get(i).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1630        extras.insert("id".into(), Value::String(id.to_string()));
1631    }
1632    extras
1633}
1634
1635fn read_load(f: &[String], raw_rev: u32, warnings: &mut Vec<String>) -> Result<Load> {
1636    // I, ID, STATUS, AREA, ZONE, PL, QL, ...
1637    let bus = id_at(f, 0, 0)?;
1638    let id = f.get(1).map_or("", |s| s.trim());
1639    let pl = num_at(f, 5, 0.0)?;
1640    let ql = num_at(f, 6, 0.0)?;
1641    let ip = num_at(f, 7, 0.0)?;
1642    let iq = num_at(f, 8, 0.0)?;
1643    let yp = num_at(f, 9, 0.0)?;
1644    let yq = num_at(f, 10, 0.0)?;
1645    let mut extras = device_extras(f, 1);
1646    for (key, value) in [
1647        ("psse_pl", pl),
1648        ("psse_ql", ql),
1649        ("psse_ip", ip),
1650        ("psse_iq", iq),
1651        ("psse_yp", yp),
1652        ("psse_yq", yq),
1653    ] {
1654        extras.insert(key.into(), jnum(value));
1655    }
1656    for (field, key, default) in [
1657        (11, "psse_owner", 1_i64),
1658        (12, "psse_scal", 1_i64),
1659        (13, "psse_intrpt", 0_i64),
1660    ] {
1661        let value = int_at(f, field, default)?;
1662        if value != default {
1663            extras.insert(key.into(), Value::from(value));
1664        }
1665    }
1666    if raw_rev >= 34 {
1667        for (field, key) in [(14, "psse_pdgen"), (15, "psse_qdgen")] {
1668            let value = num_at(f, field, 0.0)?;
1669            if value != 0.0 {
1670                extras.insert(key.into(), jnum(value));
1671            }
1672        }
1673        let flag = int_at(f, 16, 0)?;
1674        if flag != 0 {
1675            extras.insert("psse_flagstatus".into(), Value::from(flag));
1676        }
1677    }
1678    if raw_rev >= 35 {
1679        if let Some(loadtype) = f.get(17).map(|s| s.trim()).filter(|s| !s.is_empty()) {
1680            extras.insert("psse_loadtype".into(), Value::String(loadtype.to_string()));
1681        }
1682    }
1683    let scal = int_at(f, 12, 1)?;
1684    let load_type = f.get(17).and_then(|s| s.trim().parse::<i32>().ok());
1685    let has_zip_components = [ip, iq, yp, yq].iter().any(|v| *v != 0.0);
1686    let voltage_model =
1687        (has_zip_components || scal != 1 || load_type.is_some()).then_some(LoadVoltageModel::Zip {
1688            p_constant_power: pl,
1689            q_constant_power: ql,
1690            p_constant_current: ip,
1691            q_constant_current: iq,
1692            p_constant_impedance: yp,
1693            q_constant_impedance: yq,
1694            v_nom: None,
1695            load_type,
1696            scaling: (scal != 1).then_some(scal as f64),
1697        });
1698    let has_load_options = extras.contains_key("psse_intrpt")
1699        || extras.contains_key("psse_pdgen")
1700        || extras.contains_key("psse_qdgen")
1701        || extras.contains_key("psse_flagstatus");
1702    if has_load_options {
1703        warnings.push(format!(
1704            "PSS/E load at bus {bus} id {id:?}: interruptible/DG/flag fields are retained in extras"
1705        ));
1706    }
1707    Ok(Load {
1708        bus: BusId(bus),
1709        p: pl + ip + yp,
1710        q: ql + iq + yq,
1711        voltage_model,
1712        in_service: on_at(f, 2, true)?,
1713        uid: None,
1714        extras,
1715    })
1716}
1717
1718fn read_shunt(f: &[String]) -> Result<Shunt> {
1719    // I, ID, STATUS, GL, BL
1720    Ok(Shunt {
1721        bus: BusId(id_at(f, 0, 0)?),
1722        g: num_at(f, 3, 0.0)?,
1723        b: num_at(f, 4, 0.0)?,
1724        in_service: on_at(f, 2, true)?,
1725        control: None,
1726        uid: None,
1727        extras: device_extras(f, 1),
1728    })
1729}
1730
1731fn read_switched_shunt(f: &[String], rev: u32) -> Result<Shunt> {
1732    // v33/34: I, MODSW, ADJM, STAT, VSWHI, VSWLO, SWREM, RMPCT, RMIDNT, BINIT(9),
1733    // then (Ni, Bi) step pairs. v35: I, ID, MODSW, ADJM, ST, VSWHI, VSWLO,
1734    // SWREG, NREG, RMPCT, RMIDNT, BINIT(11), then (Si, Ni, Bi) triples — the ID
1735    // shifts everything by one and NREG shifts the fields after SWREG by another.
1736    // BINIT becomes the shunt `b` (gs = 0); the mode, voltage band, regulated
1737    // bus, RMPCT, and step blocks ride on the switching-control record.
1738    let o = usize::from(rev >= 35);
1739    let o2 = 2 * o;
1740    let bus = id_at(f, 0, 0)?;
1741    let swrem = id_at(f, 6 + o, 0)?;
1742    // Step blocks follow BINIT; stop at the first empty (padding) block or the
1743    // end of the record. The v35 per-block status Si leads each triple; the
1744    // neutral ShuntBlock carries no block status, so keep the block either way.
1745    let mut blocks = Vec::new();
1746    let mut i = 10 + o2;
1747    let stride = 2 + o;
1748    while i + stride <= f.len() {
1749        let steps = int_at(f, i + o, 0)?;
1750        let b = num_at(f, i + o + 1, 0.0)?;
1751        if steps == 0 && b == 0.0 {
1752            break;
1753        }
1754        blocks.push(ShuntBlock {
1755            steps: steps.clamp(0, i64::from(u32::MAX)) as u32,
1756            b,
1757        });
1758        i += stride;
1759    }
1760    let control = SwitchedShuntControl {
1761        mode: modsw_to_mode(int_at(f, 1 + o, 1)?),
1762        vhigh: num_at(f, 4 + o, 0.0)?,
1763        vlow: num_at(f, 5 + o, 0.0)?,
1764        control_bus: (swrem != 0 && swrem != bus).then_some(BusId(swrem)),
1765        rmpct: num_at(f, 7 + o2, 100.0)?,
1766        blocks,
1767    };
1768    Ok(Shunt {
1769        bus: BusId(bus),
1770        g: 0.0,
1771        b: num_at(f, 9 + o2, 0.0)?,
1772        in_service: on_at(f, 3 + o, true)?,
1773        control: Some(control),
1774        uid: None,
1775        // Keep the v35 shunt ID so it survives a round trip.
1776        extras: if rev >= 35 {
1777            device_extras(f, 1)
1778        } else {
1779            Extras::new()
1780        },
1781    })
1782}
1783
1784/// PSS/E `MODSW` switched-shunt mode code → neutral mode.
1785fn modsw_to_mode(modsw: i64) -> SwitchedShuntMode {
1786    match modsw {
1787        0 => SwitchedShuntMode::Locked,
1788        1 => SwitchedShuntMode::Continuous,
1789        _ => SwitchedShuntMode::Discrete,
1790    }
1791}
1792
1793/// Neutral switched-shunt mode → PSS/E `MODSW` (the 0/1/2 codes; modes beyond
1794/// discrete collapse to 2).
1795fn mode_to_modsw(mode: SwitchedShuntMode) -> i64 {
1796    match mode {
1797        SwitchedShuntMode::Locked => 0,
1798        SwitchedShuntMode::Continuous => 1,
1799        SwitchedShuntMode::Discrete => 2,
1800    }
1801}
1802
1803fn read_area(f: &[String]) -> Result<Area> {
1804    // I, ISW, PDES, PTOL, 'ARNAME'
1805    let isw = id_at(f, 1, 0)?;
1806    Ok(Area {
1807        number: id_at(f, 0, 0)?,
1808        slack_bus: (isw != 0).then_some(BusId(isw)),
1809        net_interchange: num_at(f, 2, 0.0)?,
1810        tolerance: num_at(f, 3, 0.0)?,
1811        name: f
1812            .get(4)
1813            .filter(|n| !n.trim().is_empty())
1814            .map(|n| n.trim().to_string()),
1815    })
1816}
1817
1818fn read_gen(f: &[String], raw_rev: u32) -> Result<Generator> {
1819    // v33/34: I, ID, PG, QG, QT, QB, VS, IREG, MBASE(8), ..., STAT(14), ...,
1820    // PT(16), PB(17). v35 inserts NREG after IREG (and BASLOD after PB),
1821    // shifting MBASE through PB by one; v34 keeps the v33 layout.
1822    let o = usize::from(raw_rev >= 35);
1823    let bus = id_at(f, 0, 0)?;
1824    // IREG names a remote regulated bus; 0 (or the generator's own bus) means it
1825    // regulates its own terminal, which the neutral model leaves as `None`.
1826    let ireg = id_at(f, 7, 0)?;
1827    Ok(Generator {
1828        bus: BusId(bus),
1829        pg: num_at(f, 2, 0.0)?,
1830        qg: num_at(f, 3, 0.0)?,
1831        qmax: num_at(f, 4, 0.0)?,
1832        qmin: num_at(f, 5, 0.0)?,
1833        vg: num_at(f, 6, 1.0)?,
1834        mbase: num_at(f, 8 + o, 100.0)?,
1835        in_service: on_at(f, 14 + o, true)?,
1836        pmax: num_at(f, 16 + o, 0.0)?,
1837        pmin: num_at(f, 17 + o, 0.0)?,
1838        cost: None,
1839        caps: Default::default(),
1840        regulated_bus: (ireg != 0 && ireg != bus).then_some(BusId(ireg)),
1841        uid: None,
1842    })
1843}
1844
1845fn read_branch(f: &[String], raw_rev: u32) -> Result<Branch> {
1846    // v33: I, J, CKT, R, X, B, RATEA, RATEB, RATEC, GI,BI,GJ,BJ, ST(13)
1847    // v34 exports insert NAME before twelve rating columns, putting STAT after
1848    // GI/BI/GJ/BJ. v33 can still have a long owner/fraction tail, so the RAW
1849    // revision, not RATEA parseability, decides the long named layout.
1850    let named_record = raw_rev >= 34 && f.len() >= 24;
1851    let rating = if named_record { 7 } else { 6 };
1852    let status = if named_record { 23 } else { 13 };
1853    let shunt = if named_record { 19 } else { 9 };
1854    let br_b = num_at(f, 5, 0.0)?;
1855    let g_fr = num_at(f, shunt, 0.0)?;
1856    let b_fr_extra = num_at(f, shunt + 1, 0.0)?;
1857    let g_to = num_at(f, shunt + 2, 0.0)?;
1858    let b_to_extra = num_at(f, shunt + 3, 0.0)?;
1859    let b_fr = br_b / 2.0 + b_fr_extra;
1860    let b_to = br_b / 2.0 + b_to_extra;
1861    Ok(Branch {
1862        from: BusId(id_at(f, 0, 0)?),
1863        to: BusId(id_at(f, 1, 0)?),
1864        r: num_at(f, 3, 0.0)?,
1865        x: num_at(f, 4, 0.0)?,
1866        b: b_fr + b_to,
1867        charging: Some(BranchCharging {
1868            g_fr,
1869            b_fr,
1870            g_to,
1871            b_to,
1872        }),
1873        rate_a: num_at(f, rating, 0.0)?,
1874        rate_b: num_at(f, rating + 1, 0.0)?,
1875        rate_c: num_at(f, rating + 2, 0.0)?,
1876        rating_sets: read_extra_branch_ratings(f, rating, named_record)?,
1877        current_ratings: None,
1878        tap: 0.0,
1879        shift: 0.0,
1880        in_service: on_at(f, status, true)?,
1881        angmin: -360.0,
1882        angmax: 360.0,
1883        control: None,
1884        solution: None,
1885        uid: None,
1886        route: None,
1887        // Capture CKT (field 2) so parallel circuits stay distinct on write-back.
1888        extras: device_extras(f, 2),
1889    })
1890}
1891
1892#[expect(clippy::too_many_arguments)]
1893fn read_transformer(
1894    l1: &[String],
1895    l2: &[String],
1896    l3: &[String],
1897    l4: &[String],
1898    raw_rev: u32,
1899    system_base: f64,
1900    bus_base_kv: &BTreeMap<BusId, f64>,
1901    warnings: &mut Vec<String>,
1902) -> Result<Branch> {
1903    // l1: I, J, K, CKT, CW, CZ, CM, MAG1, MAG2, NMETR, NAME, STAT(11)
1904    // l2: R1-2, X1-2, SBASE1-2
1905    // l3 at v33: WINDV1, NOMV1, ANG1, RATA1, RATB1, RATC1, COD1(6), CONT1,
1906    //     RMA1, RMI1, VMA1, VMI1, NTP1(12), ...
1907    // v34/35 widen the winding line to twelve ratings (RATE1..3 succeed
1908    // RATA/B/C in place) and insert NODE after CONT: COD1 lands at 15, CONT1
1909    // at 16, and RMA1..NTP1 at 18..22.
1910    // A nonzero control code COD1 marks a regulating winding; capture its limits
1911    // and regulated bus, else leave the branch's control unset.
1912    let (cw, cz) = transformer_basis_codes(l1)?;
1913    let from = BusId(id_at(l1, 0, 0)?);
1914    let to = BusId(id_at(l1, 1, 0)?);
1915    let sbase = num_at(l2, 2, system_base)?;
1916    let label = transformer_label(l1);
1917    let (r, x) = convert_transformer_impedance(
1918        num_at(l2, 0, 0.0)?,
1919        num_at(l2, 1, 0.0)?,
1920        sbase,
1921        system_base,
1922        cz,
1923        &label,
1924        "1-2",
1925        warnings,
1926    );
1927    let tap = two_winding_tap(l1, l3, l4, from, to, cw, bus_base_kv, warnings)?;
1928    let modern = raw_rev >= 34;
1929    let (cod_i, cont_i, rma_i) = if modern { (15, 16, 18) } else { (6, 7, 8) };
1930    let cod = int_at(l3, cod_i, 0)?;
1931    let control = (cod != 0)
1932        .then(|| -> Result<TransformerControl> {
1933            let cont = id_at(l3, cont_i, 0)?;
1934            Ok(TransformerControl {
1935                mode: cod_to_mode(cod),
1936                controlled_bus: (cont != 0).then_some(BusId(cont)),
1937                tap_max: num_at(l3, rma_i, 1.1)?,
1938                tap_min: num_at(l3, rma_i + 1, 0.9)?,
1939                band_max: num_at(l3, rma_i + 2, 1.1)?,
1940                band_min: num_at(l3, rma_i + 3, 0.9)?,
1941                ntp: int_at(l3, rma_i + 4, 33)?.clamp(0, i64::from(u32::MAX)) as u32,
1942                mva_base: sbase,
1943            })
1944        })
1945        .transpose()?;
1946    let mag_g = if int_at(l1, 6, 1)? == 1 {
1947        num_at(l1, 7, 0.0)?
1948    } else {
1949        0.0
1950    };
1951    let mag_b = if int_at(l1, 6, 1)? == 1 {
1952        num_at(l1, 8, 0.0)?
1953    } else {
1954        0.0
1955    };
1956    Ok(Branch {
1957        from,
1958        to,
1959        r,
1960        x,
1961        b: mag_b,
1962        charging: Some(BranchCharging {
1963            g_fr: mag_g,
1964            b_fr: mag_b,
1965            g_to: 0.0,
1966            b_to: 0.0,
1967        }),
1968        rate_a: num_at(l3, 3, 0.0)?,
1969        rate_b: num_at(l3, 4, 0.0)?,
1970        rate_c: num_at(l3, 5, 0.0)?,
1971        rating_sets: read_extra_branch_ratings(l3, 3, modern)?,
1972        current_ratings: None,
1973        tap,
1974        shift: num_at(l3, 2, 0.0)?,
1975        in_service: on_at(l1, 11, true)?,
1976        angmin: -360.0,
1977        angmax: 360.0,
1978        control,
1979        solution: None,
1980        uid: None,
1981        route: None,
1982        extras: Extras::new(),
1983    })
1984}
1985
1986/// PSS/E transformer control code `COD` → neutral control mode. The sign encodes
1987/// an enable/disable flag PSS/E carries separately; only the magnitude selects
1988/// the regulation kind.
1989fn cod_to_mode(cod: i64) -> TransformerControlMode {
1990    // `int_at` parses through f64 and saturates, so an extreme COD field can
1991    // reach i64::MIN, whose magnitude exceeds i64::MAX; `cod.abs()` would
1992    // overflow (panic under overflow checks). unsigned_abs never overflows,
1993    // and only the small magnitudes 1..=3 select a nonfixed mode anyway.
1994    match cod.unsigned_abs() {
1995        1 => TransformerControlMode::Voltage,
1996        2 => TransformerControlMode::ReactiveFlow,
1997        3 => TransformerControlMode::ActiveFlow,
1998        _ => TransformerControlMode::Fixed,
1999    }
2000}
2001
2002/// Neutral control mode → PSS/E `COD` (positive; the enable-flag sign is not modeled).
2003fn mode_to_cod(mode: TransformerControlMode) -> i64 {
2004    match mode {
2005        TransformerControlMode::Fixed => 0,
2006        TransformerControlMode::Voltage => 1,
2007        TransformerControlMode::ReactiveFlow => 2,
2008        TransformerControlMode::ActiveFlow => 3,
2009    }
2010}
2011
2012/// Read a 5-line 3-winding transformer record into a [`Transformer3W`].
2013#[expect(clippy::too_many_arguments)]
2014fn read_transformer_3w(
2015    l1: &[String],
2016    l2: &[String],
2017    l3: &[String],
2018    l4: &[String],
2019    l5: &[String],
2020    system_base: f64,
2021    bus_base_kv: &BTreeMap<BusId, f64>,
2022    warnings: &mut Vec<String>,
2023) -> Result<Transformer3W> {
2024    // l1: I, J, K, CKT, CW, CZ, CM, MAG1, MAG2, NMETR, NAME, STAT(11)
2025    // l2: R1-2,X1-2,SBASE1-2, R2-3,X2-3,SBASE2-3, R3-1,X3-1,SBASE3-1, VMSTAR, ANSTAR
2026    // l3/l4/l5: WINDVk, NOMVk, ANGk, RATAk, RATBk, RATCk, ...
2027    let (cw, cz) = transformer_basis_codes(l1)?;
2028    let label = transformer_label(l1);
2029    let buses = [
2030        BusId(id_at(l1, 0, 0)?),
2031        BusId(id_at(l1, 1, 0)?),
2032        BusId(id_at(l1, 2, 0)?),
2033    ];
2034    let z = {
2035        let mut imp = |off: usize, pair: &str| -> Result<Impedance> {
2036            let sbase = num_at(l2, off + 2, system_base)?;
2037            let (r, x) = convert_transformer_impedance(
2038                num_at(l2, off, 0.0)?,
2039                num_at(l2, off + 1, 0.0)?,
2040                sbase,
2041                system_base,
2042                cz,
2043                &label,
2044                pair,
2045                warnings,
2046            );
2047            Ok(Impedance {
2048                r,
2049                x,
2050                base_mva: sbase,
2051            })
2052        };
2053        [imp(0, "1-2")?, imp(3, "2-3")?, imp(6, "3-1")?]
2054    };
2055    let windings = {
2056        let mut winding = |idx: usize, w: &[String]| -> Result<Winding> {
2057            let bus = buses[idx];
2058            let tap = winding_ratio(
2059                w,
2060                bus,
2061                cw,
2062                bus_base_kv,
2063                &label,
2064                match idx {
2065                    0 => "winding 1",
2066                    1 => "winding 2",
2067                    _ => "winding 3",
2068                },
2069                warnings,
2070            )?;
2071            Ok(Winding {
2072                bus,
2073                tap,
2074                shift: num_at(w, 2, 0.0)?,
2075                nominal_kv: num_at(w, 1, 0.0)?,
2076                rate_a: num_at(w, 3, 0.0)?,
2077                rate_b: num_at(w, 4, 0.0)?,
2078                rate_c: num_at(w, 5, 0.0)?,
2079            })
2080        };
2081        [winding(0, l3)?, winding(1, l4)?, winding(2, l5)?]
2082    };
2083    Ok(Transformer3W {
2084        windings,
2085        z,
2086        star_vm: num_at(l2, 9, 1.0)?,
2087        star_va: num_at(l2, 10, 0.0)?,
2088        mag_g: num_at(l1, 7, 0.0)?,
2089        mag_b: num_at(l1, 8, 0.0)?,
2090        // STAT 0 = out of service; 1-4 mark which windings are in service. Treat
2091        // any nonzero status as the transformer being in service.
2092        in_service: int_at(l1, 11, 1)? != 0,
2093        name: l1
2094            .get(10)
2095            .filter(|n| !n.is_empty())
2096            .map(|n| n.trim().to_string()),
2097        uid: None,
2098        extras: Extras::new(),
2099    })
2100}
2101
2102/// Read a 3-line two-terminal DC line record into an [`Hvdc`].
2103///
2104/// The control line `l1` gives the operating mode (`MDC`), the DC line resistance
2105/// (`RDC`), the power/current demand (`SETVL`), and the scheduled DC voltage
2106/// (`VSCHD`). The rectifier and inverter lines' first field is the AC terminal
2107/// bus, which becomes the HVDC from/to. The HVDC is read as a power-setpoint
2108/// model (`pf = pt = SETVL`, no reactive output); the converter detail beyond the
2109/// buses (firing angles, converter transformer taps) is retained in extras for a
2110/// faithful write-back, not modeled electrically.
2111fn read_dc_line(l1: &[String], rect: &[String], inv: &[String]) -> Result<Hvdc> {
2112    let mdc = int_at(l1, 1, 1)?;
2113    let rdc = num_at(l1, 2, 0.0)?;
2114    let setvl = num_at(l1, 3, 0.0)?;
2115    let vschd = num_at(l1, 4, 0.0)?;
2116    let mut extras = Extras::new();
2117    if let Some(name) = l1.first().filter(|n| !n.is_empty()) {
2118        extras.insert("psse_dc_name".into(), Value::String(name.clone()));
2119    }
2120    extras.insert("psse_dc_mdc".into(), Value::from(mdc));
2121    extras.insert("psse_dc_rdc".into(), jnum(rdc));
2122    extras.insert("psse_dc_vschd".into(), jnum(vschd));
2123    extras.insert("psse_dc_control_tail".into(), tail_array(l1, 5));
2124    extras.insert("psse_dc_rectifier_tail".into(), tail_array(rect, 1));
2125    extras.insert("psse_dc_inverter_tail".into(), tail_array(inv, 1));
2126    Ok(Hvdc {
2127        from: BusId(id_at(rect, 0, 0)?),
2128        to: BusId(id_at(inv, 0, 0)?),
2129        in_service: mdc != 0,
2130        pf: setvl,
2131        pt: setvl,
2132        qf: 0.0,
2133        qt: 0.0,
2134        vf: 1.0,
2135        vt: 1.0,
2136        pmin: 0.0,
2137        pmax: setvl.abs(),
2138        qminf: 0.0,
2139        qmaxf: 0.0,
2140        qmint: 0.0,
2141        qmaxt: 0.0,
2142        loss0: 0.0,
2143        loss1: 0.0,
2144        cost: None,
2145        uid: None,
2146        extras,
2147    })
2148}
2149
2150/// The fields of `f` from index `start` as a JSON string array (for extras).
2151fn tail_array(f: &[String], start: usize) -> Value {
2152    Value::Array(
2153        f.iter()
2154            .skip(start)
2155            .map(|s| Value::String(s.clone()))
2156            .collect(),
2157    )
2158}
2159
2160/// A string-valued DC extra.
2161fn dc_str(extras: &Extras, key: &str) -> Option<String> {
2162    extras.get(key).and_then(Value::as_str).map(str::to_owned)
2163}
2164
2165/// An integer-valued DC extra.
2166fn dc_int(extras: &Extras, key: &str) -> Option<i64> {
2167    extras.get(key).and_then(Value::as_i64)
2168}
2169
2170/// A float-valued DC extra.
2171fn dc_f64(extras: &Extras, key: &str) -> Option<f64> {
2172    extras.get(key).and_then(Value::as_f64)
2173}
2174
2175/// A finite float extra carried by a read side passthrough field.
2176fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
2177    extras
2178        .get(key)
2179        .and_then(Value::as_f64)
2180        .filter(|v| v.is_finite())
2181}
2182
2183/// An integer extra carried by a read side passthrough field.
2184fn extra_i64(extras: &Extras, key: &str) -> Option<i64> {
2185    extras.get(key).and_then(Value::as_i64)
2186}
2187
2188fn same_load_total(a: f64, b: f64) -> bool {
2189    (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
2190}
2191
2192fn typed_psse_scal(l: &Load, id: &str, warnings: &mut Vec<String>) -> Option<i64> {
2193    let Some(LoadVoltageModel::Zip {
2194        scaling: Some(scaling),
2195        ..
2196    }) = &l.voltage_model
2197    else {
2198        return None;
2199    };
2200    let scaling = *scaling;
2201    if !scaling.is_finite() {
2202        warnings.push(format!(
2203            "PSS/E load at bus {} id {id:?}: non-finite typed scaling has no SCAL value; used source/default SCAL",
2204            l.bus
2205        ));
2206        return None;
2207    }
2208    let rounded = scaling.round();
2209    if (scaling - rounded).abs() > 1e-9 || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
2210        warnings.push(format!(
2211            "PSS/E load at bus {} id {id:?}: non-integer typed scaling {scaling} has no SCAL value; used source/default SCAL",
2212            l.bus
2213        ));
2214        return None;
2215    }
2216    Some(rounded as i64)
2217}
2218
2219fn typed_psse_load_type(model: &LoadVoltageModel) -> Option<String> {
2220    match model {
2221        LoadVoltageModel::Zip {
2222            load_type: Some(load_type),
2223            ..
2224        } => Some(load_type.to_string()),
2225        _ => None,
2226    }
2227}
2228
2229fn load_components_for_write(
2230    l: &Load,
2231    id: &str,
2232    warnings: &mut Vec<String>,
2233) -> (f64, f64, f64, f64, f64, f64) {
2234    if let Some(LoadVoltageModel::Zip {
2235        p_constant_power,
2236        q_constant_power,
2237        p_constant_current,
2238        q_constant_current,
2239        p_constant_impedance,
2240        q_constant_impedance,
2241        v_nom,
2242        ..
2243    }) = &l.voltage_model
2244    {
2245        if same_load_total(
2246            p_constant_power + p_constant_current + p_constant_impedance,
2247            l.p,
2248        ) && same_load_total(
2249            q_constant_power + q_constant_current + q_constant_impedance,
2250            l.q,
2251        ) {
2252            if v_nom.is_some() {
2253                warnings.push(format!(
2254                    "PSS/E load at bus {} id {id:?}: nominal voltage has no load record field; dropped",
2255                    l.bus
2256                ));
2257            }
2258            return (
2259                *p_constant_power,
2260                *q_constant_power,
2261                *p_constant_current,
2262                *q_constant_current,
2263                *p_constant_impedance,
2264                *q_constant_impedance,
2265            );
2266        }
2267        warnings.push(format!(
2268            "PSS/E load at bus {} id {id:?}: stale voltage model components did not match \
2269             typed p/q; wrote typed p/q as constant power",
2270            l.bus
2271        ));
2272        return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2273    }
2274    if matches!(l.voltage_model, Some(LoadVoltageModel::Exponential { .. })) {
2275        warnings.push(format!(
2276            "PSS/E load at bus {} id {id:?}: exponential voltage model has no load record fields; wrote typed p/q as constant power",
2277            l.bus
2278        ));
2279        return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
2280    }
2281
2282    let pl = extra_f64(&l.extras, "psse_pl").unwrap_or(l.p);
2283    let ql = extra_f64(&l.extras, "psse_ql").unwrap_or(l.q);
2284    let ip = extra_f64(&l.extras, "psse_ip").unwrap_or(0.0);
2285    let iq = extra_f64(&l.extras, "psse_iq").unwrap_or(0.0);
2286    let yp = extra_f64(&l.extras, "psse_yp").unwrap_or(0.0);
2287    let yq = extra_f64(&l.extras, "psse_yq").unwrap_or(0.0);
2288    let has_components = [
2289        "psse_pl", "psse_ql", "psse_ip", "psse_iq", "psse_yp", "psse_yq",
2290    ]
2291    .iter()
2292    .any(|key| l.extras.contains_key(*key));
2293    if has_components
2294        && (!same_load_total(pl + ip + yp, l.p) || !same_load_total(ql + iq + yq, l.q))
2295    {
2296        warnings.push(format!(
2297            "PSS/E load at bus {} id {id:?}: stale PL/QL/IP/IQ/YP/YQ extras did not match \
2298             typed p/q; wrote typed p/q as constant power",
2299            l.bus
2300        ));
2301        (l.p, l.q, 0.0, 0.0, 0.0, 0.0)
2302    } else {
2303        (pl, ql, ip, iq, yp, yq)
2304    }
2305}
2306
2307/// A retained converter-line tail joined back into a record fragment, or
2308/// `default` when the element carries none (a cross-format source).
2309fn dc_tail(extras: &Extras, key: &str, default: &str) -> String {
2310    match extras.get(key).and_then(Value::as_array) {
2311        Some(arr) if !arr.is_empty() => arr
2312            .iter()
2313            .filter_map(Value::as_str)
2314            // These come from a source file's `extras` and are replayed into
2315            // a record, so they go through the quoting seam like every other
2316            // interpolated string: a terminator here would forge a whole DC
2317            // record or a section end.
2318            .map(|f| sanitize_quoted(f, NAME_FORBIDDEN, ' ').into_owned())
2319            .collect::<Vec<_>>()
2320            .join(", "),
2321        _ => default.to_string(),
2322    }
2323}
2324
2325#[cfg(test)]
2326mod tests {
2327    use super::*;
2328
2329    fn close(actual: f64, expected: f64) {
2330        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2331    }
2332
2333    #[test]
2334    fn extreme_transformer_cod_does_not_overflow() {
2335        // `int_at` parses COD through f64 and saturates, so `-1e300` becomes
2336        // i64::MIN; the old `cod.abs()` would overflow (panic under overflow
2337        // checks). The field decodes to Fixed instead.
2338        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2339CASE
2340COMMENT
23411,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
23422,'BUS2        ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
23430 / END OF BUS DATA, BEGIN LOAD DATA
23440 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
23450 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
23460 / END OF GENERATOR DATA, BEGIN BRANCH DATA
23470 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
23481,2,0,'1 ',2,2,1,0,0,1,'xf',1
23490.01,0.10,50.0
2350241.5,230.0,0.0,100.0,90.0,80.0,-1e300,0,1.1,0.9,1.1,0.9,33
2351115.0,115.0
23520 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2353Q
2354";
2355        let parsed = crate::parse_str(raw, "psse").unwrap();
2356        let control = parsed.network.branches[0].control.as_ref().unwrap();
2357        assert_eq!(control.mode, TransformerControlMode::Fixed);
2358    }
2359
2360    fn test_bus(id: usize, kind: BusType) -> Bus {
2361        Bus {
2362            id: BusId(id),
2363            kind,
2364            vm: 1.0,
2365            va: 0.0,
2366            base_kv: 230.0,
2367            vmax: 1.1,
2368            vmin: 0.9,
2369            evhi: None,
2370            evlo: None,
2371            area: 1,
2372            zone: 1,
2373            name: None,
2374            uid: None,
2375            location: None,
2376            extras: Extras::default(),
2377        }
2378    }
2379
2380    fn branch_with_terminal_charging() -> Branch {
2381        Branch {
2382            from: BusId(1),
2383            to: BusId(2),
2384            r: 0.01,
2385            x: 0.1,
2386            b: 0.0,
2387            charging: Some(BranchCharging {
2388                g_fr: 0.01,
2389                b_fr: 0.02,
2390                g_to: 0.03,
2391                b_to: 0.05,
2392            }),
2393            rate_a: 100.0,
2394            rate_b: 110.0,
2395            rate_c: 120.0,
2396            rating_sets: Vec::new(),
2397            current_ratings: None,
2398            tap: 0.0,
2399            shift: 0.0,
2400            in_service: true,
2401            angmin: -360.0,
2402            angmax: 360.0,
2403            control: None,
2404            solution: None,
2405            uid: None,
2406            route: None,
2407            extras: Extras::default(),
2408        }
2409    }
2410
2411    fn transformer_with_terminal_charging(charging: BranchCharging) -> Branch {
2412        Branch {
2413            from: BusId(1),
2414            to: BusId(2),
2415            r: 0.01,
2416            x: 0.1,
2417            b: 0.0,
2418            charging: Some(charging),
2419            rate_a: 100.0,
2420            rate_b: 110.0,
2421            rate_c: 120.0,
2422            rating_sets: Vec::new(),
2423            current_ratings: None,
2424            tap: 1.05,
2425            shift: 0.0,
2426            in_service: true,
2427            angmin: -360.0,
2428            angmax: 360.0,
2429            control: None,
2430            solution: None,
2431            uid: None,
2432            route: None,
2433            extras: Extras::default(),
2434        }
2435    }
2436
2437    fn assert_terminal_charging_round_trip(text: &str) {
2438        let back = parse_psse(text).unwrap();
2439        let charging = back.branches[0].terminal_charging();
2440        close(charging.g_fr, 0.01);
2441        close(charging.b_fr, 0.02);
2442        close(charging.g_to, 0.03);
2443        close(charging.b_to, 0.05);
2444        close(back.branches[0].b, 0.07);
2445    }
2446
2447    #[test]
2448    fn branch_terminal_charging_writes_gi_bi_gj_bj() {
2449        let mut net = BalancedNetwork::in_memory(
2450            "terminal-shunts",
2451            100.0,
2452            vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2453            Vec::new(),
2454        );
2455        net.branches.push(branch_with_terminal_charging());
2456
2457        let rev33 = write_psse(&net);
2458        assert!(rev33.warnings.is_empty(), "{:?}", rev33.warnings);
2459        assert_terminal_charging_round_trip(&rev33.text);
2460
2461        let rev35 = write_psse_rev(&net, 35);
2462        assert!(rev35.warnings.is_empty(), "{:?}", rev35.warnings);
2463        assert_terminal_charging_round_trip(&rev35.text);
2464    }
2465
2466    #[test]
2467    fn transformer_magnetizing_admittance_writes_mag1_mag2() {
2468        let mut net = BalancedNetwork::in_memory(
2469            "xfmr-mag",
2470            100.0,
2471            vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2472            Vec::new(),
2473        );
2474        net.branches
2475            .push(transformer_with_terminal_charging(BranchCharging {
2476                g_fr: 0.01,
2477                b_fr: 0.02,
2478                g_to: 0.0,
2479                b_to: 0.0,
2480            }));
2481
2482        let conv = write_psse(&net);
2483        assert!(
2484            !conv
2485                .warnings
2486                .iter()
2487                .any(|w| w.contains("magnetizing admittance")),
2488            "{:?}",
2489            conv.warnings
2490        );
2491        let back = parse_psse(&conv.text).unwrap();
2492        let charging = back.branches[0].terminal_charging();
2493        close(charging.g_fr, 0.01);
2494        close(charging.b_fr, 0.02);
2495        close(charging.g_to, 0.0);
2496        close(charging.b_to, 0.0);
2497        close(back.branches[0].b, 0.02);
2498    }
2499
2500    #[test]
2501    fn transformer_to_side_terminal_admittance_warns_and_collapses_to_mag() {
2502        let mut net = BalancedNetwork::in_memory(
2503            "xfmr-mag-collapse",
2504            100.0,
2505            vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
2506            Vec::new(),
2507        );
2508        net.branches
2509            .push(transformer_with_terminal_charging(BranchCharging {
2510                g_fr: 0.01,
2511                b_fr: 0.02,
2512                g_to: 0.03,
2513                b_to: 0.05,
2514            }));
2515
2516        let conv = write_psse(&net);
2517        assert!(
2518            conv.warnings
2519                .iter()
2520                .any(|w| w.contains("magnetizing admittance")),
2521            "{:?}",
2522            conv.warnings
2523        );
2524        let back = parse_psse(&conv.text).unwrap();
2525        let charging = back.branches[0].terminal_charging();
2526        close(charging.g_fr, 0.04);
2527        close(charging.b_fr, 0.07);
2528        close(charging.g_to, 0.0);
2529        close(charging.b_to, 0.0);
2530        close(back.branches[0].b, 0.07);
2531    }
2532
2533    #[test]
2534    fn slash_inside_a_quoted_field_is_not_a_comment() {
2535        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2536CASE
2537COMMENT
25381,'A/B         ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25390 / END OF BUS DATA, BEGIN LOAD DATA
2540Q
2541";
2542
2543        let net = parse_psse(raw).unwrap();
2544
2545        assert_eq!(net.buses.len(), 1);
2546        assert_eq!(net.buses[0].name.as_deref(), Some("A/B"));
2547    }
2548
2549    #[test]
2550    fn load_zip_components_are_typed_and_round_trip() {
2551        let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2552CASE
2553COMMENT
25540 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
25551,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25562,'BUS2        ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
25570 / END OF BUS DATA, BEGIN LOAD DATA
25582,'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'
25590 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2560Q
2561";
2562        let mut warnings = Vec::new();
2563        let net =
2564            parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
2565
2566        assert_eq!(net.loads.len(), 1);
2567        close(net.loads[0].p, 13.0);
2568        close(net.loads[0].q, 5.0);
2569        let Some(LoadVoltageModel::Zip {
2570            p_constant_power,
2571            q_constant_current,
2572            p_constant_impedance,
2573            ..
2574        }) = &net.loads[0].voltage_model
2575        else {
2576            panic!("missing typed ZIP load model");
2577        };
2578        close(*p_constant_power, 10.0);
2579        close(*q_constant_current, 0.5);
2580        close(*p_constant_impedance, 2.0);
2581        assert!(
2582            warnings.iter().any(|w| w.contains("interruptible/DG/flag")),
2583            "missing load option warning: {warnings:?}"
2584        );
2585
2586        let text = write_psse_rev(&net, 35).text;
2587        assert!(
2588            text.contains("10.0, 3.0, 1.0, 0.5, 2.0, 1.5"),
2589            "ZIP components were not replayed: {text}"
2590        );
2591        assert!(
2592            text.contains("4.0, 2.0, 1, 'industrial'"),
2593            "modern load tail was not replayed: {text}"
2594        );
2595        let net2 = parse_psse(&text).unwrap();
2596        close(net2.loads[0].p, 13.0);
2597        close(net2.loads[0].q, 5.0);
2598    }
2599
2600    #[test]
2601    fn tiny_nonzero_zip_components_are_preserved_as_typed_fields() {
2602        let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2603CASE
2604COMMENT
26050 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
26061,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26072,'BUS2        ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26080 / END OF BUS DATA, BEGIN LOAD DATA
26092,'L1',1,1,1,10.0,3.0,1e-20,0.0,0.0,0.0,1,1,0,0.0,0.0,0,''
26100 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2611Q
2612";
2613        let net = parse_psse(raw).unwrap();
2614        let Some(LoadVoltageModel::Zip {
2615            p_constant_current, ..
2616        }) = &net.loads[0].voltage_model
2617        else {
2618            panic!("tiny nonzero ZIP component was not typed");
2619        };
2620        assert_eq!(p_constant_current.to_bits(), 1.0e-20_f64.to_bits());
2621
2622        let matpower = crate::format::matpower::write_matpower_conversion(&net);
2623        assert!(
2624            matpower
2625                .warnings
2626                .iter()
2627                .any(|w| w.contains("voltage dependent load model")),
2628            "missing MATPOWER voltage model warning: {:?}",
2629            matpower.warnings
2630        );
2631    }
2632
2633    #[test]
2634    fn typed_psse_load_scaling_and_type_write_without_extras() {
2635        let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2636CASE
2637COMMENT
26380 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
26391,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26402,'BUS2        ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
26410 / END OF BUS DATA, BEGIN LOAD DATA
26422,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,1,0,0.0,0.0,0,''
26430 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2644Q
2645";
2646        let mut net = parse_psse(raw).unwrap();
2647        let Some(LoadVoltageModel::Zip {
2648            scaling,
2649            load_type,
2650            v_nom,
2651            ..
2652        }) = &mut net.loads[0].voltage_model
2653        else {
2654            panic!("missing typed ZIP load model");
2655        };
2656        *scaling = Some(0.0);
2657        *load_type = Some(7);
2658        *v_nom = Some(230_000.0);
2659        net.loads[0].extras.remove("psse_scal");
2660        net.loads[0].extras.remove("psse_loadtype");
2661
2662        let conv = write_psse_rev(&net, 35);
2663
2664        assert!(
2665            conv.text.contains(", 1, 0, 0, 0.0, 0.0, 0, '7'"),
2666            "typed SCAL/LOADTYPE were not written: {}",
2667            conv.text
2668        );
2669        assert!(
2670            conv.warnings.iter().any(|w| w.contains("nominal voltage")),
2671            "missing nominal voltage warning: {:?}",
2672            conv.warnings
2673        );
2674        let rev33 = write_psse(&net);
2675        assert!(
2676            rev33
2677                .warnings
2678                .iter()
2679                .any(|w| w.contains("load type requires revision 35")),
2680            "missing rev33 load type warning: {:?}",
2681            rev33.warnings
2682        );
2683        let reparsed = parse_psse(&conv.text).unwrap();
2684        let Some(LoadVoltageModel::Zip {
2685            scaling, load_type, ..
2686        }) = &reparsed.loads[0].voltage_model
2687        else {
2688            panic!("missing reparsed ZIP load model");
2689        };
2690        assert_eq!(*scaling, Some(0.0));
2691        assert_eq!(*load_type, Some(7));
2692    }
2693
2694    #[test]
2695    fn mutated_load_does_not_replay_stale_psse_zip_extras() {
2696        let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
2697CASE
2698COMMENT
26990 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
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
27032,'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'
27040 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
2705Q
2706";
2707        let mut net = parse_psse(raw).unwrap();
2708        net.loads[0].p = 20.0;
2709        net.loads[0].q = 7.0;
2710
2711        let conv = write_psse_rev(&net, 35);
2712
2713        assert!(
2714            conv.text.contains("20.0, 7.0, 0.0, 0.0, 0.0, 0.0"),
2715            "typed p/q were not written as constant power: {}",
2716            conv.text
2717        );
2718        assert!(
2719            conv.warnings
2720                .iter()
2721                .any(|w| w.contains("stale voltage model components")),
2722            "missing stale voltage model warning: {:?}",
2723            conv.warnings
2724        );
2725        let reparsed = parse_psse(&conv.text).unwrap();
2726        close(reparsed.loads[0].p, 20.0);
2727        close(reparsed.loads[0].q, 7.0);
2728    }
2729
2730    #[test]
2731    fn transformer_continuation_rejects_section_terminator() {
2732        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2733CASE
2734COMMENT
27351,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27362,'BUS2        ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27370 / END OF BUS DATA, BEGIN LOAD DATA
27380 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27390 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27400 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27410 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27421,2,0,'1 ',1,1,1,0,0,1,'xf'
27430 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2744Q
2745";
2746        let err = parse_psse(raw).unwrap_err().to_string();
2747        assert!(
2748            err.contains("transformer record ended before transformer impedance line"),
2749            "{err}"
2750        );
2751    }
2752
2753    #[test]
2754    fn transformer_impedance_line_can_start_with_zero_resistance() {
2755        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2756CASE
2757COMMENT
27581,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27592,'BUS2        ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27600 / END OF BUS DATA, BEGIN LOAD DATA
27610 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27620 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27630 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27640 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27651,2,0,'1 ',1,1,1,0,0,1,'xf',1
27660,0.10,100.0
27671.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27681.0,230.0
27690 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2770Q
2771";
2772        let net = parse_psse(raw).unwrap();
2773
2774        assert_eq!(net.branches.len(), 1);
2775        close(net.branches[0].r, 0.0);
2776        close(net.branches[0].x, 0.10);
2777    }
2778
2779    #[test]
2780    fn transformer_non_integral_cz_is_a_hard_error() {
2781        // A malformed CZ like `2.9` must not silently truncate to a valid
2782        // looking code `2`; that would apply the wrong impedance base
2783        // conversion without ever surfacing an "unsupported CZ" warning.
2784        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2785CASE
2786COMMENT
27871,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27882,'BUS2        ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
27890 / END OF BUS DATA, BEGIN LOAD DATA
27900 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
27910 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
27920 / END OF GENERATOR DATA, BEGIN BRANCH DATA
27930 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
27941,2,0,'1 ',1,2.9,1,0,0,1,'xf',1
27950,0.10,100.0
27961.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
27971.0,230.0
27980 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2799Q
2800";
2801        let err = parse_psse(raw).unwrap_err().to_string();
2802        assert!(err.contains("field 5") && err.contains("2.9"), "{err}");
2803    }
2804
2805    #[test]
2806    fn non_unit_two_winding_transformer_bases_are_converted() {
2807        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2808CASE
2809COMMENT
28101,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28112,'BUS2        ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28120 / END OF BUS DATA, BEGIN LOAD DATA
28130 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28140 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28150 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28160 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28171,2,0,'1 ',2,2,1,0,0,1,'xf',1
28180.01,0.10,50.0
2819241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2820115.0,115.0
28210 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2822Q
2823";
2824        let parsed = crate::parse_str(raw, "psse").unwrap();
2825        assert!(
2826            !parsed
2827                .warnings
2828                .iter()
2829                .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2830            "unexpected transformer base warning: {:?}",
2831            parsed.warnings
2832        );
2833        let br = &parsed.network.branches[0];
2834        close(br.r, 0.02);
2835        close(br.x, 0.20);
2836        close(br.tap, 1.05);
2837    }
2838
2839    #[test]
2840    fn cz3_load_loss_and_cw3_nominal_voltage_are_converted() {
2841        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2842CASE
2843COMMENT
28441,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28452,'BUS2        ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28460 / END OF BUS DATA, BEGIN LOAD DATA
28470 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28480 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28490 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28500 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28511,2,0,'1 ',3,3,1,0,0,1,'xf',1
2852250000.0,0.10,50.0
28531.05,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
28541.0,115.0
28550 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2856Q
2857";
2858        let parsed = crate::parse_str(raw, "psse").unwrap();
2859        assert!(
2860            !parsed
2861                .warnings
2862                .iter()
2863                .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2864            "unexpected transformer base warning: {:?}",
2865            parsed.warnings
2866        );
2867        let br = &parsed.network.branches[0];
2868        close(br.r, 0.01);
2869        close(br.x, (0.10_f64 * 0.10 - 0.005_f64 * 0.005).sqrt() * 2.0);
2870        close(br.tap, 1.05);
2871    }
2872
2873    #[test]
2874    fn non_unit_three_winding_transformer_bases_are_converted() {
2875        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2876CASE
2877COMMENT
28781,'BUS1        ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28792,'BUS2        ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28803,'BUS3        ', 13.8,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
28810 / END OF BUS DATA, BEGIN LOAD DATA
28820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
28830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
28840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
28850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
28861,2,3,'1 ',2,2,1,0,0,1,'xf3',1
28870.01,0.10,50.0,0.02,0.20,100.0,0.03,0.30,200.0,1.0,0.0
2888241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
2889115.0,115.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
289013.8,13.8,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
28910 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2892Q
2893";
2894        let parsed = crate::parse_str(raw, "psse").unwrap();
2895        assert!(
2896            !parsed
2897                .warnings
2898                .iter()
2899                .any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
2900            "unexpected transformer base warning: {:?}",
2901            parsed.warnings
2902        );
2903        let t = &parsed.network.transformers_3w[0];
2904        close(t.z[0].r, 0.02);
2905        close(t.z[0].x, 0.20);
2906        close(t.z[1].r, 0.02);
2907        close(t.z[1].x, 0.20);
2908        close(t.z[2].r, 0.015);
2909        close(t.z[2].x, 0.15);
2910        close(t.windings[0].tap, 1.05);
2911        close(t.windings[1].tap, 1.0);
2912        close(t.windings[2].tap, 1.0);
2913    }
2914
2915    #[test]
2916    fn dc_continuation_rejects_section_terminator() {
2917        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
2918CASE
2919COMMENT
29200 / END OF SYSTEM-WIDE DATA, BEGIN TWO-TERMINAL DC DATA
2921'DC1',1
29220 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
2923Q
2924";
2925        let err = parse_psse(raw).unwrap_err().to_string();
2926        assert!(
2927            err.contains("two-terminal DC record ended before rectifier line"),
2928            "{err}"
2929        );
2930    }
2931
2932    #[test]
2933    fn reads_comment_headers_system_wide_block_and_named_branch_records() {
2934        let raw = r#"@!IC, SBASE,REV,XFRRAT,NXFRAT,BASFRQ
29350, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2936
2937
2938GENERAL, THRSHZ=0.0002
2939RATING, 1, "      ", "                                "
29400 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
2941@!   I,'NAME        ', BASKV, IDE,AREA,ZONE,OWNER, VM,        VA,    NVHI,   NVLO,   EVHI,   EVLO
29421,'BUS1        ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
29432,'BUS2        ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
29440 / END OF BUS DATA, BEGIN LOAD DATA
2945@!   I,'ID',STAT,AREA,ZONE,      PL,        QL
29462,'1 ',1,1,1,10.0,5.0
29470 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
29480 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
2949@!   I,'ID',      PG,        QG,        QT,        QB,     VS,    IREG,     MBASE,     ZR,         ZX,         RT,         XT,     GTAP,STAT, RMPCT,      PT,        PB
29501,'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
29510 / END OF GENERATOR DATA, BEGIN BRANCH DATA
2952@!   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
29531,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
29540 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
29550 / END OF TRANSFORMER DATA, BEGIN AREA DATA
2956Q
2957"#;
2958
2959        let mut net = parse_psse(raw).unwrap();
2960
2961        close(net.base_mva, 100.0);
2962        assert_eq!(net.buses.len(), 2);
2963        assert_eq!(net.loads.len(), 1);
2964        assert_eq!(net.generators.len(), 1);
2965        assert_eq!(net.branches.len(), 1);
2966        close(net.branches[0].rate_a, 100.0);
2967        assert_eq!(net.branches[0].rating_sets.len(), 2);
2968        assert_eq!(net.branches[0].rating_sets[0].name, "RATE4");
2969        close(net.branches[0].rating_sets[0].rate_mva, 70.0);
2970        assert_eq!(net.branches[0].rating_sets[1].name, "RATE6");
2971        close(net.branches[0].rating_sets[1].rate_mva, 60.0);
2972        assert!(net.branches[0].in_service);
2973
2974        net.source = None;
2975        let written = write_psse_rev(&net, 34);
2976        assert!(
2977            !written.warnings.iter().any(|w| w.contains("rating set")),
2978            "v34 should carry RATE4-RATE12, got {:?}",
2979            written.warnings
2980        );
2981        let back = parse_psse(&written.text).unwrap();
2982        assert_eq!(back.branches[0].rating_sets.len(), 2);
2983        assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
2984        close(back.branches[0].rating_sets[0].rate_mva, 70.0);
2985        assert_eq!(back.branches[0].rating_sets[1].name, "RATE6");
2986        close(back.branches[0].rating_sets[1].rate_mva, 60.0);
2987    }
2988
2989    #[test]
2990    fn v34_transformer_reads_float_k_and_modern_winding_columns() {
2991        // v34/35 exporters write K in float form: "0.00" must classify the
2992        // record as 2-winding (4 lines), or the reader consumes a fifth line and
2993        // desynchronizes every later section. The winding line uses the v34
2994        // layout (twelve ratings, NODE after CONT), putting COD at 15 and
2995        // RMA..NTP at 18..22.
2996        let raw = r"0, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
2997CASE
2998COMMENT
29990 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
30001,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30012,'B2          ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
30020 / END OF BUS DATA, BEGIN LOAD DATA
30030 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
30040 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
30050 / END OF GENERATOR DATA, BEGIN BRANCH DATA
30060 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
30071, 2, 0.00, '1', 1, 1, 1, 0.0, 0.0, 2, 'T1          ', 1, 1, 1.0, 0, 1, 0, 1, 0, 1, '            '
30080.01, 0.10, 100.0
30091.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
30101.0, 0.0
30110 / END OF TRANSFORMER DATA, BEGIN AREA DATA
30121, 1, 0.0, 0.0, 'AREA        '
3013Q
3014";
3015        let net = parse_psse(raw).unwrap();
3016        assert_eq!(net.branches.len(), 1, "K = 0.00 is a 2-winding record");
3017        assert!(net.transformers_3w.is_empty());
3018        assert_eq!(
3019            net.areas.len(),
3020            1,
3021            "the section after the transformer parsed"
3022        );
3023        let br = &net.branches[0];
3024        close(br.tap, 1.05);
3025        close(br.rate_a, 100.0);
3026        assert_eq!(br.rating_sets.len(), 1);
3027        assert_eq!(br.rating_sets[0].name, "RATE4");
3028        close(br.rating_sets[0].rate_mva, 70.0);
3029        let c = br.control.as_ref().expect("COD at 15 marks the control");
3030        assert_eq!(c.mode, TransformerControlMode::Voltage);
3031        assert_eq!(c.controlled_bus, Some(BusId(2)));
3032        close(c.tap_max, 1.08);
3033        close(c.tap_min, 0.92);
3034        close(c.band_max, 1.05);
3035        close(c.band_min, 0.98);
3036        assert_eq!(c.ntp, 17);
3037    }
3038
3039    #[test]
3040    fn v34_warns_when_custom_rating_name_is_emitted_as_rate_slot() {
3041        let mut net = BalancedNetwork::in_memory(
3042            "ratings",
3043            100.0,
3044            vec![
3045                Bus::new(BusId(1), BusType::Ref, 230.0),
3046                Bus::new(BusId(2), BusType::Pq, 230.0),
3047            ],
3048            Vec::new(),
3049        );
3050        let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.05);
3051        branch.rate_a = 100.0;
3052        branch
3053            .rating_sets
3054            .push(BranchRatingSet::new("emergency", 125.0));
3055        net.branches.push(branch);
3056
3057        let written = write_psse_rev(&net, 34);
3058
3059        assert!(
3060            written.warnings.iter().any(|w| {
3061                w.contains("rating set emergency=125")
3062                    && w.contains("emitted as RATE4")
3063                    && w.contains("names outside RATE4-RATE12 are not preserved")
3064            }),
3065            "missing rating rename warning: {:?}",
3066            written.warnings
3067        );
3068        let back = parse_psse(&written.text).unwrap();
3069        assert_eq!(back.branches[0].rating_sets.len(), 1);
3070        assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
3071        close(back.branches[0].rating_sets[0].rate_mva, 125.0);
3072    }
3073
3074    #[test]
3075    fn reads_start_of_section_markers_and_gen_alias() {
3076        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3077CASE
3078COMMENT
30791,'BUS1        ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30802,'BUS2        ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
30810 / End of Bus Data, Start of Load Data
30822,'1 ',1,1,1,10.0,5.0
30830 / End of Load Data, Start of Fixed Shunt Data
30840 / End of Fixed Shunt Data, Start of Gen Data
30851,'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
30860 / End of Gen Data, Start of Branch Data
30871,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
30880 / End of Branch Data, Start of Transformer Data
30890 / End of Transformer Data, Start of Area Interchange Data
3090Q
3091";
3092
3093        let net = parse_psse(raw).unwrap();
3094
3095        assert_eq!(net.buses.len(), 2);
3096        assert_eq!(net.loads.len(), 1);
3097        assert_eq!(net.generators.len(), 1);
3098        assert_eq!(net.branches.len(), 1);
3099    }
3100
3101    #[test]
3102    fn v33_long_branch_with_blank_ratea_keeps_v33_columns() {
3103        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
3104CASE
3105COMMENT
31061,'BUS1        ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
31072,'BUS2        ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
31080 / END OF BUS DATA, BEGIN LOAD DATA
31090 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31100 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31110 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31121,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
31130 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31140 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3115Q
3116";
3117
3118        let net = parse_psse(raw).unwrap();
3119
3120        assert_eq!(net.branches.len(), 1);
3121        close(net.branches[0].rate_a, 0.0);
3122        close(net.branches[0].rate_b, 90.0);
3123        close(net.branches[0].rate_c, 80.0);
3124        assert!(net.branches[0].in_service);
3125    }
3126
3127    #[test]
3128    fn captured_load_ids_round_trip_and_parallel_loads_stay_distinct() {
3129        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3130CASE
3131COMMENT
31321,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31332,'B2          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31340 / END OF BUS DATA, BEGIN LOAD DATA
31352,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
31362,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
31370 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31380 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31390 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31400 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31410 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3142Q
3143";
3144        let id = |l: &Load| {
3145            l.extras
3146                .get("id")
3147                .and_then(|v| v.as_str())
3148                .map(str::to_owned)
3149        };
3150        let net = parse_psse(raw).unwrap();
3151        assert_eq!(net.loads.len(), 2);
3152        assert_eq!(id(&net.loads[0]).as_deref(), Some("A"));
3153        assert_eq!(id(&net.loads[1]).as_deref(), Some("B"));
3154
3155        // A round trip keeps the captured ids.
3156        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3157        assert_eq!(id(&net2.loads[0]).as_deref(), Some("A"));
3158        assert_eq!(id(&net2.loads[1]).as_deref(), Some("B"));
3159
3160        // With the ids stripped (a synthesized network, e.g. from MATPOWER), the
3161        // two loads on bus 2 still write with distinct positional ids, so the
3162        // output is valid PSS/E rather than two colliding (bus, '1') records.
3163        let mut synth = net.clone();
3164        for l in &mut synth.loads {
3165            l.extras.remove("id");
3166        }
3167        let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3168        let ids: Vec<_> = net3.loads.iter().filter_map(&id).collect();
3169        assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3170    }
3171
3172    #[test]
3173    fn sanitized_load_ids_are_allocated_after_cleaning() {
3174        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3175CASE
3176COMMENT
31771,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31782,'B2          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
31790 / END OF BUS DATA, BEGIN LOAD DATA
31802,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
31812,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
31820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
31830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
31840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
31850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
31860 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3187Q
3188";
3189        let mut net = parse_psse(raw).unwrap();
3190        net.loads[0]
3191            .extras
3192            .insert("id".into(), Value::String("A/B".into()));
3193        net.loads[1]
3194            .extras
3195            .insert("id".into(), Value::String("A'B".into()));
3196
3197        let conv = write_psse(&net);
3198        let reparsed = parse_psse(&conv.text).unwrap();
3199        let ids: Vec<_> = reparsed
3200            .loads
3201            .iter()
3202            .filter_map(|l| l.extras.get("id").and_then(Value::as_str))
3203            .collect();
3204
3205        assert_eq!(ids, vec!["A B", "1"]);
3206        assert!(
3207            conv.warnings
3208                .iter()
3209                .any(|w| w.contains("2 quoted PSS/E field")),
3210            "missing sanitation warning: {:?}",
3211            conv.warnings
3212        );
3213    }
3214
3215    #[test]
3216    fn two_winding_transformer_charging_round_trips_via_mag2() {
3217        // MAG2 (line-1 field 8) carries the transformer's magnetizing susceptance;
3218        // at CM = 1 it maps to the branch charging b and must survive a round trip.
3219        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3220CASE
3221COMMENT
32221,'B1          ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
32232,'B2          ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
32240 / END OF BUS DATA, BEGIN LOAD DATA
32250 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
32260 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
32270 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32280 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32291, 2, 0, '1', 1, 1, 1, 0, 0.04, 2, 'XF          ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
32300.01, 0.10, 100.0
32311.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
32321.0, 0
32330 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3234Q
3235";
3236        let net = parse_psse(raw).unwrap();
3237        assert_eq!(net.branches.len(), 1);
3238        assert!(net.branches[0].is_transformer());
3239        close(net.branches[0].b, 0.04);
3240
3241        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3242        close(net2.branches[0].b, 0.04);
3243    }
3244
3245    #[test]
3246    fn parallel_branches_round_trip_and_stay_distinct() {
3247        // Two circuits between buses 1 and 2: each keeps a distinct CKT so the
3248        // output is valid PSS/E rather than two colliding (I, J, '1') records.
3249        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3250CASE
3251COMMENT
32521,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32532,'B2          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
32540 / END OF BUS DATA, BEGIN LOAD DATA
32550 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
32560 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
32570 / END OF GENERATOR DATA, BEGIN BRANCH DATA
32581,2,'1 ',0.01,0.05,0.001,0,0,0,0,0,0,0,1,1,0.0
32591,2,'2 ',0.02,0.06,0.002,0,0,0,0,0,0,0,1,1,0.0
32600 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
32610 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3262Q
3263";
3264        let ckt = |b: &Branch| {
3265            b.extras
3266                .get("id")
3267                .and_then(|v| v.as_str())
3268                .map(str::to_owned)
3269        };
3270        let net = parse_psse(raw).unwrap();
3271        assert_eq!(net.branches.len(), 2);
3272        assert_eq!(ckt(&net.branches[0]).as_deref(), Some("1"));
3273        assert_eq!(ckt(&net.branches[1]).as_deref(), Some("2"));
3274
3275        // Round trip keeps both circuits distinct.
3276        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3277        assert_eq!(net2.branches.len(), 2);
3278        assert_eq!(ckt(&net2.branches[0]).as_deref(), Some("1"));
3279        assert_eq!(ckt(&net2.branches[1]).as_deref(), Some("2"));
3280
3281        // With the captured ids stripped (a synthesized network), the two parallel
3282        // branches still write with distinct positional circuit ids.
3283        let mut synth = net.clone();
3284        for b in &mut synth.branches {
3285            b.extras.remove("id");
3286        }
3287        let net3 = parse_psse(&write_psse(&synth).text).unwrap();
3288        let ids: Vec<_> = net3.branches.iter().filter_map(&ckt).collect();
3289        assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
3290    }
3291
3292    #[test]
3293    fn reads_and_writes_solver_params() {
3294        let raw = r"0, 100.00, 34, 0, 1, 60.00 / x
3295CASE
3296COMMENT
3297GENERAL, THRSHZ=0.0001
3298NEWTON, TOLN=0.1, ITMXN=25
3299SOLVER, ACTAPS=1, AREAIN=0, PHSHFT=1, DCTAPS=1, SWSHNT=0
33000 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
33011,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33020 / END OF BUS DATA, BEGIN LOAD DATA
3303Q
3304";
3305        let net = parse_psse(raw).unwrap();
3306        let sp = net.solver.as_ref().expect("solver params parsed");
3307        close(sp.zero_impedance_threshold.unwrap(), 0.0001);
3308        close(sp.newton_tolerance.unwrap(), 0.1);
3309        assert_eq!(sp.max_iterations, Some(25));
3310        assert_eq!(sp.adjust_taps, Some(true));
3311        assert_eq!(sp.adjust_area_interchange, Some(false));
3312        assert_eq!(sp.adjust_phase_shift, Some(true));
3313        assert_eq!(sp.adjust_switched_shunt, Some(false));
3314
3315        // Round trip at rev 34 keeps the tolerances and the adjustment flags.
3316        let net2 = parse_psse(&write_psse_rev(&net, 34).text).unwrap();
3317        let sp2 = net2
3318            .solver
3319            .as_ref()
3320            .expect("solver params survive the write");
3321        close(sp2.newton_tolerance.unwrap(), 0.1);
3322        assert_eq!(sp2.max_iterations, Some(25));
3323        assert_eq!(sp2.adjust_taps, Some(true));
3324        assert_eq!(sp2.adjust_area_interchange, Some(false));
3325    }
3326
3327    #[test]
3328    fn reads_and_writes_area_records() {
3329        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3330CASE
3331COMMENT
33321,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33335,'B5          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33340 / END OF BUS DATA, BEGIN LOAD DATA
33350 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33360 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33370 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33380 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33390 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33401, 5, 100.0, 10.0, 'AREA-ONE    '
33410 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3342Q
3343";
3344        let net = parse_psse(raw).unwrap();
3345        assert_eq!(net.areas.len(), 1, "the area record was read");
3346        let a = &net.areas[0];
3347        assert_eq!(a.number, 1);
3348        assert_eq!(a.slack_bus, Some(BusId(5)));
3349        close(a.net_interchange, 100.0);
3350        close(a.tolerance, 10.0);
3351        assert_eq!(a.name.as_deref(), Some("AREA-ONE"));
3352
3353        // Round trip: write and re-read keeps the interchange and swing bus.
3354        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3355        assert_eq!(net2.areas.len(), 1);
3356        let a2 = &net2.areas[0];
3357        assert_eq!(a2.number, 1);
3358        assert_eq!(a2.slack_bus, Some(BusId(5)));
3359        close(a2.net_interchange, 100.0);
3360        assert_eq!(a2.name.as_deref(), Some("AREA-ONE"));
3361    }
3362
3363    #[test]
3364    fn reads_and_writes_a_switched_shunt() {
3365        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3366CASE
3367COMMENT
33681,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33693,'B3          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33707,'B7          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
33710 / END OF BUS DATA, BEGIN LOAD DATA
33720 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
33730 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
33740 / END OF GENERATOR DATA, BEGIN BRANCH DATA
33750 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
33760 / END OF TRANSFORMER DATA, BEGIN AREA DATA
33770 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
33783, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
33790 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3380Q
3381";
3382        let net = parse_psse(raw).unwrap();
3383        assert_eq!(net.shunts.len(), 1);
3384        let sh = &net.shunts[0];
3385        assert_eq!(sh.bus, BusId(3));
3386        close(sh.b, 19.0);
3387        let c = sh.control.as_ref().expect("switched-shunt control parsed");
3388        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3389        close(c.vhigh, 1.05);
3390        close(c.vlow, 0.95);
3391        assert_eq!(c.control_bus, Some(BusId(7)));
3392        close(c.rmpct, 100.0);
3393        assert_eq!(c.blocks.len(), 2);
3394        assert_eq!(c.blocks[0].steps, 2);
3395        close(c.blocks[0].b, 25.0);
3396        assert_eq!(c.blocks[1].steps, 1);
3397        close(c.blocks[1].b, 50.0);
3398
3399        // Round trip: written to the SWITCHED SHUNT section and re-read intact.
3400        let text = write_psse(&net).text;
3401        assert!(text.contains("BEGIN SWITCHED SHUNT DATA"));
3402        let net2 = parse_psse(&text).unwrap();
3403        assert_eq!(net2.shunts.len(), 1);
3404        let c2 = net2.shunts[0]
3405            .control
3406            .as_ref()
3407            .expect("control survives the write");
3408        assert_eq!(c2.mode, SwitchedShuntMode::Discrete);
3409        assert_eq!(c2.control_bus, Some(BusId(7)));
3410        assert_eq!(c2.blocks.len(), 2);
3411        close(c2.blocks[0].b, 25.0);
3412        close(net2.shunts[0].b, 19.0);
3413    }
3414
3415    #[test]
3416    fn v35_switched_shunt_write_round_trips_through_the_id_column() {
3417        // v35 inserts a quoted shunt ID at field 1 and NREG after SWREG, and its
3418        // step blocks are (S, N, B) triples; the writer must emit that layout or
3419        // the reader misplaces every later field. Build a switched shunt, write
3420        // the v35 layout, and confirm it reads back intact.
3421        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3422CASE
3423COMMENT
34243,'B3          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34257,'B7          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34260 / END OF BUS DATA, BEGIN LOAD DATA
34270 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34280 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34290 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34300 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34310 / END OF TRANSFORMER DATA, BEGIN AREA DATA
34320 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
34333, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
34340 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3435Q
3436";
3437        let net = parse_psse(raw).unwrap();
3438        let text = write_psse_rev(&net, 35).text;
3439        let net2 = parse_psse(&text).unwrap();
3440        assert_eq!(net2.shunts.len(), 1);
3441        let sh = &net2.shunts[0];
3442        assert_eq!(sh.bus, BusId(3));
3443        close(sh.b, 19.0);
3444        let c = sh
3445            .control
3446            .as_ref()
3447            .expect("v35 switched-shunt control survives the write");
3448        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3449        close(c.vhigh, 1.05);
3450        close(c.vlow, 0.95);
3451        assert_eq!(c.control_bus, Some(BusId(7)));
3452        close(c.rmpct, 100.0);
3453        assert_eq!(c.blocks.len(), 2);
3454        close(c.blocks[0].b, 25.0);
3455        close(c.blocks[1].b, 50.0);
3456    }
3457
3458    #[test]
3459    fn reads_and_writes_a_generator_remote_regulated_bus() {
3460        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3461CASE
3462COMMENT
34631,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34643,'B3          ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34657,'B7          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
34660 / END OF BUS DATA, BEGIN LOAD DATA
34670 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
34680 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
34693,'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
34701,'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
34710 / END OF GENERATOR DATA, BEGIN BRANCH DATA
34720 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
34730 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3474Q
3475";
3476        let net = parse_psse(raw).unwrap();
3477        assert_eq!(net.generators.len(), 2);
3478        let g3 = net.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3479        assert_eq!(
3480            g3.regulated_bus,
3481            Some(BusId(7)),
3482            "IREG names the remote regulated bus"
3483        );
3484        // IREG 0 means own-terminal control: no remote bus.
3485        let g1 = net.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3486        assert_eq!(g1.regulated_bus, None);
3487
3488        // Round trip: IREG is written at field 7 and re-read intact.
3489        let text = write_psse(&net).text;
3490        let net2 = parse_psse(&text).unwrap();
3491        let g3b = net2.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
3492        assert_eq!(g3b.regulated_bus, Some(BusId(7)));
3493        let g1b = net2.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
3494        assert_eq!(g1b.regulated_bus, None);
3495    }
3496
3497    #[test]
3498    fn reads_a_v35_generator_record_with_nreg() {
3499        // v35 inserts NREG after IREG (and BASLOD after PB), shifting MBASE,
3500        // STAT, PT, and PB by one. Reading at the v33 offsets takes NREG as
3501        // MBASE and GTAP (1.0) as STAT, silently returning this out of service
3502        // unit to service.
3503        let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3504CASE
3505COMMENT
35061,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35070 / END OF BUS DATA, BEGIN LOAD DATA
35080 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35090 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35101,'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
35110 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35120 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35130 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3514Q
3515";
3516        let net = parse_psse(raw).unwrap();
3517        assert_eq!(net.generators.len(), 1);
3518        let g = &net.generators[0];
3519        close(g.mbase, 900.0);
3520        assert!(!g.in_service, "STAT = 0 at the shifted index");
3521        close(g.pmax, 80.0);
3522        close(g.pmin, 10.0);
3523        assert_eq!(g.regulated_bus, None, "IREG stays at field 7");
3524
3525        // The v35 writer emits NREG and BASLOD, so the record reads back intact.
3526        let net2 = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
3527        let g2 = &net2.generators[0];
3528        close(g2.mbase, 900.0);
3529        assert!(!g2.in_service);
3530        close(g2.pmax, 80.0);
3531        close(g2.pmin, 10.0);
3532    }
3533
3534    #[test]
3535    fn stale_control_pointers_warn_and_drop() {
3536        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3537CASE
3538COMMENT
35391,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35402,'B2          ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
35410 / END OF BUS DATA, BEGIN LOAD DATA
35420 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
35430 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
35441,'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
35450 / END OF GENERATOR DATA, BEGIN BRANCH DATA
35460 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
35471, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
35480.01, 0.10, 100.0
35491.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
35501.0, 0
35510 / END OF TRANSFORMER DATA, BEGIN AREA DATA
35521, 97, 0.0, 0.0, 'AREA        '
35530 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
35540 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
35550 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
35560 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
35570 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
35580 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
35590 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
35600 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
35610 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA
35620 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA
35632, 2, 0, 1, 1.05, 0.95, 96, 100.0, '', 19.0, 2, 25.0
35640 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3565Q
3566";
3567        let mut warnings = Vec::new();
3568        let net =
3569            parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3570
3571        assert_eq!(net.generators[0].regulated_bus, None);
3572        assert_eq!(
3573            net.branches[0]
3574                .control
3575                .as_ref()
3576                .and_then(|c| c.controlled_bus),
3577            None
3578        );
3579        assert_eq!(
3580            net.shunts[0].control.as_ref().and_then(|c| c.control_bus),
3581            None
3582        );
3583        assert_eq!(net.areas[0].slack_bus, None);
3584        assert!(
3585            warnings.iter().any(|w| w.contains("GENERATOR DATA")
3586                && w.contains("IREG")
3587                && w.contains("missing bus id 99")),
3588            "missing IREG warning: {warnings:?}"
3589        );
3590        assert!(
3591            warnings.iter().any(|w| w.contains("TRANSFORMER DATA")
3592                && w.contains("CONT")
3593                && w.contains("missing bus id 98")),
3594            "missing CONT warning: {warnings:?}"
3595        );
3596        assert!(
3597            warnings.iter().any(|w| w.contains("SWITCHED SHUNT DATA")
3598                && w.contains("SWREM")
3599                && w.contains("missing bus id 96")),
3600            "missing SWREM warning: {warnings:?}"
3601        );
3602        assert!(
3603            warnings.iter().any(|w| w.contains("AREA DATA")
3604                && w.contains("ISW")
3605                && w.contains("missing bus id 97")),
3606            "missing ISW warning: {warnings:?}"
3607        );
3608    }
3609
3610    #[test]
3611    fn truncated_transformer_continuation_names_expected_line() {
3612        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3613CASE
3614COMMENT
36151,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36162,'B2          ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36170 / END OF BUS DATA, BEGIN LOAD DATA
36180 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36190 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36200 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36210 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36221, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
36230 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3624Q
3625";
3626        let err = parse_psse(raw).unwrap_err().to_string();
3627        assert!(
3628            err.contains("transformer record ended before transformer impedance line"),
3629            "got {err}"
3630        );
3631    }
3632
3633    #[test]
3634    fn unmodeled_section_counts_skip_bare_terminators() {
3635        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3636CASE
3637COMMENT
36381,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36390 / END OF BUS DATA, BEGIN LOAD DATA
36400 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36410 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36420 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36430 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36440 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36450 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
36460 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3647'VSC1', 1
36482, 3
36490
36500 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
3651Q
3652";
3653        let mut warnings = Vec::new();
3654        parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
3655        assert!(
3656            warnings
3657                .iter()
3658                .any(|w| w.contains("VSC DC LINE section (2 record line(s))")),
3659            "bare terminator should not be counted as skipped data: {warnings:?}"
3660        );
3661    }
3662
3663    #[test]
3664    fn reads_a_v35_switched_shunt_with_an_id_column() {
3665        // v35: I, ID, MODSW, ADJM, ST, VSWHI, VSWLO, SWREG, NREG, RMPCT, RMIDNT,
3666        // BINIT, then (S, N, B) triples. Reading it at the v33 offsets misparses
3667        // VSWLO as SWREM (regression: a real v35 case pointed switched-shunt
3668        // control at a nonexistent bus 1) and NREG as RMPCT.
3669        let raw = "0, 100.00, 35, 0, 0, 60.00 / x
3670CASE
3671COMMENT
36725,'B5          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36737,'B7          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
36740 / END OF BUS DATA, BEGIN LOAD DATA
36750 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
36760 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
36770 / END OF GENERATOR DATA, BEGIN BRANCH DATA
36780 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
36790 / END OF TRANSFORMER DATA, BEGIN AREA DATA
36800 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
36815,'1 ',2,0,1,1.05,0.95,7,3,80.0,'',19.0,1,2,25.0,0,1,50.0
36820 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
3683Q
3684";
3685        let net = parse_psse(raw).unwrap();
3686        assert_eq!(net.shunts.len(), 1);
3687        let sh = &net.shunts[0];
3688        assert_eq!(sh.bus, BusId(5));
3689        close(sh.b, 19.0);
3690        assert!(sh.in_service);
3691        let c = sh.control.as_ref().expect("switched-shunt control parsed");
3692        assert_eq!(c.mode, SwitchedShuntMode::Discrete);
3693        close(c.vhigh, 1.05);
3694        close(c.vlow, 0.95);
3695        assert_eq!(
3696            c.control_bus,
3697            Some(BusId(7)),
3698            "SWREG at field 7, not NREG at 8"
3699        );
3700        close(c.rmpct, 80.0);
3701        // Both (S, N, B) blocks are kept; the leading status column is skipped.
3702        assert_eq!(c.blocks.len(), 2);
3703        assert_eq!(c.blocks[0].steps, 2);
3704        close(c.blocks[0].b, 25.0);
3705        assert_eq!(c.blocks[1].steps, 1);
3706        close(c.blocks[1].b, 50.0);
3707    }
3708
3709    #[test]
3710    fn reads_and_writes_a_two_terminal_dc_line() {
3711        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3712CASE
3713COMMENT
37141,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37154,'B4          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37165,'B5          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
37170 / END OF BUS DATA, BEGIN LOAD DATA
37180 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37190 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37200 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37210 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37220 / END OF TRANSFORMER DATA, BEGIN AREA DATA
37230 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
3724'DCLINE1', 1, 2.5, 350.0, 500.0, 0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0
37254, 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
37265, 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
37270 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
3728Q
3729";
3730        let net = parse_psse(raw).unwrap();
3731        assert_eq!(net.hvdc.len(), 1, "the two-terminal DC line was read");
3732        let dc = &net.hvdc[0];
3733        assert_eq!(dc.from, BusId(4), "rectifier bus is the from end");
3734        assert_eq!(dc.to, BusId(5), "inverter bus is the to end");
3735        assert!(dc.in_service);
3736        close(dc.pf, 350.0);
3737        close(dc.pt, 350.0);
3738
3739        // Round trip: write and re-read keeps the buses and the power setpoint.
3740        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3741        assert_eq!(net2.hvdc.len(), 1, "the DC line survives the write");
3742        let dc2 = &net2.hvdc[0];
3743        assert_eq!(dc2.from, BusId(4));
3744        assert_eq!(dc2.to, BusId(5));
3745        assert!(dc2.in_service);
3746        close(dc2.pf, 350.0);
3747    }
3748
3749    #[test]
3750    fn reads_and_writes_a_regulating_transformer_control() {
3751        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3752CASE
3753COMMENT
37541,'B1          ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37552,'B2          ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37563,'B3          ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37570 / END OF BUS DATA, BEGIN LOAD DATA
37580 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
37590 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
37600 / END OF GENERATOR DATA, BEGIN BRANCH DATA
37610 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
37621, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
37630.01, 0.10, 100.0
37641.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
37651.0, 0
37660 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3767Q
3768";
3769        let net = parse_psse(raw).unwrap();
3770        assert_eq!(net.branches.len(), 1);
3771        let c = net.branches[0].control.as_ref().expect("control parsed");
3772        assert_eq!(c.mode, TransformerControlMode::Voltage);
3773        assert_eq!(c.controlled_bus, Some(BusId(3)));
3774        close(c.tap_max, 1.08);
3775        close(c.tap_min, 0.92);
3776        close(c.band_min, 0.98);
3777        assert_eq!(c.ntp, 17);
3778        close(c.mva_base, 100.0);
3779
3780        // Round trip: write and re-read keeps the control block and the tap/shift.
3781        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3782        let c2 = net2.branches[0].control.as_ref().expect("control survives");
3783        assert_eq!(c2.mode, TransformerControlMode::Voltage);
3784        assert_eq!(c2.controlled_bus, Some(BusId(3)));
3785        close(c2.tap_max, 1.08);
3786        assert_eq!(c2.ntp, 17);
3787        close(net2.branches[0].tap, 1.025);
3788        close(net2.branches[0].shift, 2.5);
3789    }
3790
3791    #[test]
3792    fn reads_and_writes_a_three_winding_transformer() {
3793        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3794CASE
3795COMMENT
37961,'B1          ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37972,'B2          ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37983,'B3          ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
37990 / END OF BUS DATA, BEGIN LOAD DATA
38000 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38010 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38020 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38030 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38041, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
38050.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
38061.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
38071.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38080.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38090 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3810Q
3811";
3812        let net = parse_psse(raw).unwrap();
3813        assert_eq!(
3814            net.transformers_3w.len(),
3815            1,
3816            "the 3-winding record was read"
3817        );
3818        assert!(net.branches.is_empty(), "a 3W is not folded into branches");
3819        let t = &net.transformers_3w[0];
3820        assert_eq!(
3821            [t.windings[0].bus, t.windings[1].bus, t.windings[2].bus],
3822            [BusId(1), BusId(2), BusId(3)]
3823        );
3824        close(t.z[0].r, 0.01);
3825        close(t.z[2].x, 0.30);
3826        close(t.windings[0].rate_a, 100.0);
3827        close(t.windings[1].tap, 1.025);
3828        close(t.windings[2].shift, 30.0);
3829        close(t.star_vm, 0.98);
3830        close(t.star_va, -1.5);
3831
3832        // Round trip: write and re-read keeps the windings and the star voltage.
3833        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3834        assert_eq!(net2.transformers_3w.len(), 1);
3835        assert!(net2.branches.is_empty());
3836        let t2 = &net2.transformers_3w[0];
3837        close(t2.z[1].x, 0.20);
3838        close(t2.windings[2].tap, 0.95);
3839        close(t2.star_va, -1.5);
3840        assert_eq!(t2.name.as_deref(), Some("T3W"));
3841    }
3842
3843    #[test]
3844    fn three_winding_cross_format_warns_and_survives_normalization() {
3845        // Same 3-winding record plus a slack generator so to_normalized has a
3846        // reference to anchor.
3847        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3848CASE
3849COMMENT
38501,'B1          ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38512,'B2          ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38523,'B3          ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
38530 / END OF BUS DATA, BEGIN LOAD DATA
38540 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38550 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38561,'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
38570 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38580 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38591, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '
38600.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
38611.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
38621.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38630.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
38640 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3865Q
3866";
3867        let net = parse_psse(raw).unwrap();
3868        assert_eq!(net.transformers_3w.len(), 1);
3869
3870        // Cross-format write to MATPOWER drops the 3W but must report it, not drop
3871        // it silently.
3872        let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3873        assert!(
3874            mpc.warnings.iter().any(|w| w.contains("3-winding")),
3875            "MATPOWER write must warn on the dropped 3-winding transformer, got {:?}",
3876            mpc.warnings
3877        );
3878
3879        // The normalized form keeps the 3-winding transformer.
3880        let norm = net.to_normalized().unwrap();
3881        assert_eq!(norm.transformers_3w.len(), 1, "to_normalized keeps the 3W");
3882        norm.validate().unwrap();
3883    }
3884
3885    #[test]
3886    fn writing_a_different_revision_re_emits_instead_of_echoing() {
3887        // A PSS/E v33 source echoes byte-for-byte when written back as v33, but a
3888        // request for v34 must re-emit the v34 layout, not return the v33 bytes.
3889        let raw = "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.1,0.9
38930 / END OF BUS DATA, BEGIN LOAD DATA
38940 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
38950 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
38960 / END OF GENERATOR DATA, BEGIN BRANCH DATA
38970 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
38980 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3899Q
3900";
3901        let parsed = crate::parse_str(raw, "psse").unwrap();
3902        let same = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 33 }).unwrap();
3903        assert_eq!(same.text, raw, "same revision echoes the retained source");
3904        let v34 = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 34 }).unwrap();
3905        assert_ne!(v34.text, raw, "a different revision must re-emit, not echo");
3906        assert!(
3907            v34.text.contains("END OF SYSTEM-WIDE DATA"),
3908            "v34 output carries the system-wide marker, got:\n{}",
3909            v34.text
3910        );
3911    }
3912
3913    #[test]
3914    fn warns_on_a_nonempty_unmodeled_section() {
3915        // A substation (node-breaker) section is not modeled; reading must report
3916        // it rather than drop it silently.
3917        let raw = "0, 100.00, 34, 0, 0, 60.00 / x
3918CASE
3919COMMENT
39201,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39210 / END OF BUS DATA, BEGIN LOAD DATA
39220 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39230 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39240 / END OF GENERATOR DATA, BEGIN BRANCH DATA
39250 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39260 / END OF TRANSFORMER DATA, BEGIN AREA DATA
39270 / END OF AREA DATA, BEGIN SUBSTATION DATA
39281, 'SUB1', 21.3, -157.8, 0.001
39290 / END OF SUBSTATION DATA, BEGIN GNE DEVICE DATA
3930Q
3931";
3932        let parsed = crate::parse_str(raw, "psse").unwrap();
3933        assert!(
3934            parsed
3935                .warnings
3936                .iter()
3937                .any(|w| w.contains("SUBSTATION") && w.contains("not modeled")),
3938            "an unmodeled substation section must be reported, got {:?}",
3939            parsed.warnings
3940        );
3941    }
3942
3943    #[test]
3944    fn reads_writes_and_drops_an_emergency_voltage_band() {
3945        // Bus 1 has a distinct EVHI/EVLO (1.2/0.8) vs the normal band (1.1/0.9);
3946        // bus 2's emergency band equals its normal band.
3947        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3948CASE
3949COMMENT
39501,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.2,0.8
39512,'B2          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
39520 / END OF BUS DATA, BEGIN LOAD DATA
39530 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
39540 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
39551,'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
39560 / END OF GENERATOR DATA, BEGIN BRANCH DATA
39570 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
39580 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3959Q
3960";
3961        let net = parse_psse(raw).unwrap();
3962        let b1 = net.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3963        assert!(
3964            b1.evhi.is_some() && b1.evlo.is_some(),
3965            "distinct band typed"
3966        );
3967        close(b1.evhi.unwrap(), 1.2);
3968        close(b1.evlo.unwrap(), 0.8);
3969        let b2 = net.buses.iter().find(|b| b.id == BusId(2)).unwrap();
3970        assert!(
3971            b2.evhi.is_none() && b2.evlo.is_none(),
3972            "an emergency band equal to the normal band stays None"
3973        );
3974
3975        // Round trip through the PSS/E writer keeps the distinct band.
3976        let net2 = parse_psse(&write_psse(&net).text).unwrap();
3977        let r1 = net2.buses.iter().find(|b| b.id == BusId(1)).unwrap();
3978        close(r1.evhi.unwrap(), 1.2);
3979        close(r1.evlo.unwrap(), 0.8);
3980
3981        // A cross-format write to MATPOWER (single voltage band) reports the drop.
3982        let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
3983        assert!(
3984            mpc.warnings
3985                .iter()
3986                .any(|w| w.contains("emergency voltage band")),
3987            "MATPOWER write must warn on the dropped emergency band, got {:?}",
3988            mpc.warnings
3989        );
3990    }
3991
3992    #[test]
3993    fn a_case_name_with_a_terminator_cannot_forge_a_bus_record() {
3994        // The case name reaches the header and title lines verbatim. A
3995        // terminator inside it used to end the record, so the rest parsed as
3996        // bus data and the written file described a network the source never
3997        // had.
3998        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
3999CASE
4000COMMENT
40011,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40020 / END OF BUS DATA, BEGIN LOAD DATA
40030 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40040 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40050 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40060 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40070 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4008Q
4009";
4010        let mut net = parse_psse(raw).unwrap();
4011        net.name =
4012            "A\n42, 'INJECTED   ', 500.0, 2, 9, 9, 1, 1.0, 0.0, 1.1, 0.9, 1.1, 0.9".to_owned();
4013        net.source = None; // force a real write, not the byte-exact echo
4014        let text = write_psse(&net).text;
4015        let back = parse_psse(&text).unwrap();
4016        assert_eq!(back.buses.len(), 1, "forged bus record in:\n{text}");
4017    }
4018
4019    #[test]
4020    fn writes_v34_v35_layouts_that_round_trip() {
4021        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
4022CASE
4023COMMENT
40241,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40252,'B2          ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
40260 / END OF BUS DATA, BEGIN LOAD DATA
40272,'1',1,1,1,10.0,5.0,0,0,0,0,1,1,0
40280 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40290 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40300 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40311,2,'1 ',0.01,0.05,0.001,111.0,90.0,80.0,0,0,0,0,1,1,0,1,1
40320 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40330 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4034Q
4035";
4036        let net = parse_psse(raw).unwrap();
4037
4038        for rev in [34u32, 35] {
4039            let text = write_psse_rev(&net, rev).text;
4040            // v34+ wraps the globals in a system-wide section with its end marker.
4041            assert!(
4042                text.contains("END OF SYSTEM-WIDE DATA, BEGIN BUS DATA"),
4043                "rev {rev} missing the system-wide marker"
4044            );
4045            let header = text.lines().next().unwrap();
4046            assert!(header.contains(&format!(", {rev}, ")), "header {header:?}");
4047            // The branch uses the named 12-rating layout (>= 24 comma fields).
4048            let branch = text.lines().find(|l| l.starts_with("1, 2, '1'")).unwrap();
4049            assert!(
4050                branch.split(',').count() >= 24,
4051                "rev {rev} branch is not the named layout: {branch:?}"
4052            );
4053
4054            let back = parse_psse(&text).unwrap();
4055            assert_eq!(back.buses.len(), 2);
4056            assert_eq!(back.loads.len(), 1);
4057            assert_eq!(back.branches.len(), 1);
4058            close(back.branches[0].rate_a, 111.0);
4059            close(back.loads[0].p, 10.0);
4060            assert!(back.branches[0].in_service);
4061        }
4062
4063        // The v35 load record carries the trailing LOADTYPE field.
4064        assert!(
4065            write_psse_rev(&net, 35).text.contains(", ''"),
4066            "v35 load should carry a LOADTYPE field"
4067        );
4068    }
4069
4070    #[test]
4071    fn writer_sanitizes_bus_names_that_would_corrupt_a_record() {
4072        // A name with an apostrophe closes the single-quoted field early; a name
4073        // with '/' truncates the record at the inline-comment delimiter. Either
4074        // shifts every later column. The writer replaces both and warns, so the
4075        // second bus's base kV survives the round trip.
4076        let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
4077CASE
4078COMMENT
40791,'BUS1        ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
40802,'BUS2        ', 138.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
40810 / END OF BUS DATA, BEGIN LOAD DATA
40820 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
40830 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
40840 / END OF GENERATOR DATA, BEGIN BRANCH DATA
40850 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
40860 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4087Q
4088";
4089        let mut net = parse_psse(raw).unwrap();
4090        net.buses[0].name = Some("O'Brien/X".to_string());
4091
4092        let conv = write_psse(&net);
4093        let reparsed = parse_psse(&conv.text).unwrap();
4094
4095        assert_eq!(reparsed.buses.len(), 2);
4096        close(reparsed.buses[0].base_kv, 230.0);
4097        close(reparsed.buses[1].base_kv, 138.0);
4098        let name = reparsed.buses[0].name.as_deref().unwrap();
4099        assert!(!name.contains('\'') && !name.contains('/'), "got {name:?}");
4100        assert!(
4101            conv.warnings
4102                .iter()
4103                .any(|w| w.contains("quoted PSS/E field")),
4104            "expected a sanitization warning, got {:?}",
4105            conv.warnings
4106        );
4107    }
4108
4109    #[test]
4110    fn malformed_first_bus_id_is_not_treated_as_system_wide_data() {
4111        let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic malformed export
4112CASE
4113COMMENT
4114BAD,'BUS1        ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
41150 / END OF BUS DATA, BEGIN LOAD DATA
4116Q
4117";
4118
4119        let err = parse_psse(raw).unwrap_err();
4120
4121        assert!(
4122            err.to_string().contains("bus record missing numeric id"),
4123            "malformed bus id should be reported directly: {err}"
4124        );
4125    }
4126}