Skip to main content

powerio/format/powerworld/
map.rs

1//! Map a parsed [`AuxFile`] to the typed [`Network`], and write a `Network`
2//! back out as aux text.
3//!
4//! Only the power flow core object types (Bus, Load, Shunt, Gen, Branch) feed
5//! the typed model; every other `DATA` section stays reachable through the
6//! generic layer (see [`super::aux`]) and survives the same format round trip
7//! via the retained source.
8
9use std::collections::{BTreeMap, HashMap};
10use std::fmt::Write as _;
11use std::sync::Arc;
12
13use super::auxiliary::{AuxFile, AuxObject, parse_aux};
14use crate::format::{Conversion, sanitize_quoted, warn_extra_branch_rating_sets};
15use crate::network::{
16    Branch, Bus, BusId, BusType, Extras, Generator, Load, LoadVoltageModel, Network, Shunt,
17    SourceFormat,
18};
19use crate::{Error, Result};
20
21const FMT: &str = "PowerWorld .aux";
22
23/// The double quote would close a PowerWorld quoted value early on re-read (the
24/// tokenizer toggles on `"` with no un-escaping), shifting every later column.
25const NAME_FORBIDDEN: &[char] = &['"'];
26
27/// Branch identity extras keys, shared with the `.pwb` reader. They double as
28/// the aux field names (extras keep PowerWorld fields verbatim), so every
29/// PowerWorld reader produces the same extras.
30pub(super) const LINE_CIRCUIT: &str = "LineCircuit";
31pub(super) const BRANCH_DEVICE_TYPE: &str = "BranchDeviceType";
32
33// ---- Reader -----------------------------------------------------------------
34
35/// Owned-source entry used by the format hub: parse by borrowing `source`, then
36/// move the buffer into the retained source (no copy). `name_hint` (e.g. a file
37/// stem) names the network when the `.aux` carries no export marker.
38pub(crate) fn parse_powerworld_source(
39    source: Arc<String>,
40    name_hint: Option<&str>,
41    warnings: &mut Vec<String>,
42) -> Result<Network> {
43    let content: &str = &source;
44    // PowerWorld `.aux` does not carry the system base in the case data, so we
45    // default to 100 MVA (the de-facto standard, and what our own writer records
46    // in the `// baseMVA` marker below). Reading a real base from PowerWorld's
47    // project files is tracked separately; defaulting here is deliberate, not a
48    // silent guess — erroring would reject every base-less third-party `.aux`.
49    let mut base_mva = 100.0;
50    let mut name = name_hint.unwrap_or("case").to_string();
51    for line in content.lines() {
52        let t = line.trim();
53        if let Some(rest) = t.strip_prefix("// baseMVA ") {
54            if let Ok(v) = rest.trim().parse::<f64>() {
55                base_mva = v;
56            }
57        } else if let Some((_, n)) = t.split_once("powerio export: ") {
58            name = n.trim().to_string();
59        }
60    }
61
62    let aux = parse_aux(content)?;
63    if aux.data().next().is_none() {
64        return Err(Error::FormatRead {
65            format: FMT,
66            message: "no DATA blocks found".into(),
67        });
68    }
69
70    // A complete case export spreads one object type over several DATA
71    // sections, each declaring a different field group for the same objects
72    // (Simulator 19 era exports write Bus twice, Gen three times, and put the
73    // transformer regulation fields in a separate `Transformer` object).
74    // Merge sections by the type's key fields before mapping; a later section
75    // updates the fields it declares, exactly like loading the aux into
76    // Simulator would.
77    let mut merged_buses = Merge::new(&[&["BusNum", "Number"]]);
78    let mut merged_loads = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["LoadID", "ID"]]);
79    let mut merged_shunts = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["ShuntID", "ID"]]);
80    let mut merged_gens = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["GenID", "ID"]]);
81    let mut merged_branches = Merge::new(&[
82        &["BusNum", "BusNumFrom", "BusName_NomVolt"],
83        &["BusNum:1", "BusNumTo", "BusName_NomVolt:1"],
84        &[LINE_CIRCUIT, "Circuit"],
85    ]);
86    let mut unmodeled: BTreeMap<&str, usize> = BTreeMap::new();
87    for blk in aux.data() {
88        match blk.object_type.as_str() {
89            "Bus" => merged_buses.absorb(
90                blk,
91                blk.field_index("BusNum").is_some() || blk.field_index("Number").is_some(),
92            ),
93            "Load" => merged_loads.absorb(blk, true),
94            "Shunt" => merged_shunts.absorb(blk, true),
95            "Gen" => merged_gens.absorb(blk, true),
96            "Branch" => merged_branches.absorb(blk, true),
97            // Transformer sections augment existing branches with regulation
98            // fields; a transformer with no Branch record carries no impedance
99            // and cannot stand alone, so unmatched rows are not created.
100            "Transformer" => merged_branches.absorb(blk, false),
101            _ => {
102                if !blk.rows.is_empty() {
103                    *unmodeled.entry(&blk.object_type).or_default() += blk.rows.len();
104                }
105            }
106        }
107    }
108    warnings.extend(unmodeled.into_iter().map(|(object, rows)| {
109        format!(
110            "PowerWorld .aux DATA {object} has {rows} row(s) not modeled in Network; \
111             retained only in source text for same-format writeback"
112        )
113    }));
114
115    let mut buses = Vec::new();
116    let mut bus_labels = HashMap::new();
117    for r in merged_buses.rows() {
118        let bus = read_bus(r)?;
119        if let Some(label) = first(r, &["BusName_NomVolt"]) {
120            bus_labels.insert(label, bus.id);
121        }
122        buses.push(bus);
123    }
124    let mut loads = Vec::new();
125    for r in merged_loads.rows() {
126        loads.push(read_load(r, &bus_labels)?);
127    }
128    let mut shunts = Vec::new();
129    for r in merged_shunts.rows() {
130        shunts.push(read_shunt(r, &bus_labels)?);
131    }
132    let mut generators = Vec::new();
133    for r in merged_gens.rows() {
134        generators.push(read_gen(r, &bus_labels)?);
135    }
136    let mut branches = Vec::new();
137    for r in merged_branches.rows() {
138        branches.push(read_branch(r, &bus_labels)?);
139    }
140    derive_bus_kinds(&mut buses, &generators);
141
142    let net = Network {
143        name,
144        base_mva,
145        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
146        buses,
147        loads,
148        shunts,
149        branches,
150        switches: Vec::new(),
151        generators,
152        storage: Vec::new(),
153        hvdc: Vec::new(),
154        transformers_3w: Vec::new(),
155        areas: Vec::new(),
156        solver: None,
157        source_format: SourceFormat::PowerWorld,
158        source: Some(source),
159    };
160    net.check_references(FMT)?;
161    Ok(net)
162}
163
164/// Parse the auxiliary sections of a PowerWorld-sourced [`Network`]'s retained
165/// source. The typed model carries the power flow core; everything else in the
166/// original file (contingencies, limit sets, substations, ...) is reachable
167/// here.
168///
169/// Returns `None` when the network was not read from a `.aux` source.
170///
171/// # Errors
172/// As [`parse_aux`], on a retained source that no longer parses.
173pub fn aux_sections(net: &Network) -> Option<Result<AuxFile>> {
174    if net.source_format != SourceFormat::PowerWorld {
175        return None;
176    }
177    net.source.as_ref().map(|s| parse_aux(s))
178}
179
180type Row<'a> = HashMap<&'a str, &'a str>;
181
182/// Merges the rows of one object type across its DATA sections, keyed by the
183/// type's key fields. Insertion order is kept, so the first section fixes the
184/// element order and later sections update fields in place.
185#[derive(PartialEq, Eq, Hash)]
186enum MergeKey<'a> {
187    Fields(Vec<&'a str>),
188    /// A section with none of the type's key columns identifies its rows by
189    /// position (our own writer's output identifies devices by order).
190    Ordinal(usize),
191}
192
193struct Merge<'a> {
194    /// Key columns as alias groups: each group lists the same key under its
195    /// naming generations (`BusNum`/`Number`, `LineCircuit`/`Circuit`, ...);
196    /// a section keys on whichever name it declares.
197    key_fields: &'static [&'static [&'static str]],
198    index: HashMap<MergeKey<'a>, usize>,
199    merged: Vec<Row<'a>>,
200}
201
202impl<'a> Merge<'a> {
203    fn new(key_fields: &'static [&'static [&'static str]]) -> Self {
204        Merge {
205            key_fields,
206            index: HashMap::new(),
207            merged: Vec::new(),
208        }
209    }
210
211    /// Fold a DATA section in. With `create`, rows whose key is unseen become
212    /// new elements; otherwise they are dropped (augmentation only sections,
213    /// like `Transformer`).
214    fn absorb(&mut self, blk: &'a AuxObject, create: bool) {
215        let positions: Vec<Vec<usize>> = self
216            .key_fields
217            .iter()
218            .map(|group| group.iter().filter_map(|k| blk.field_index(k)).collect())
219            .collect();
220        let keyless = positions.iter().all(Vec::is_empty);
221        for (at, row) in blk.rows.iter().enumerate() {
222            let key = if keyless {
223                MergeKey::Ordinal(at)
224            } else {
225                MergeKey::Fields(
226                    positions
227                        .iter()
228                        .map(|aliases| {
229                            aliases
230                                .iter()
231                                .filter_map(|i| row.values.get(*i).map(|v| v.as_str().trim()))
232                                .find(|v| !v.is_empty())
233                                .unwrap_or("")
234                        })
235                        .collect(),
236                )
237            };
238            let slot = match self.index.get(&key) {
239                Some(&i) => i,
240                None if create => {
241                    self.index.insert(key, self.merged.len());
242                    self.merged.push(HashMap::with_capacity(blk.fields.len()));
243                    self.merged.len() - 1
244                }
245                None => continue,
246            };
247            let entry = &mut self.merged[slot];
248            for (f, v) in blk.fields.iter().zip(&row.values) {
249                entry.insert(f.as_str(), v.as_str());
250            }
251        }
252    }
253
254    fn rows(&self) -> impl Iterator<Item = &Row<'a>> {
255        self.merged.iter()
256    }
257}
258
259fn bad_field(key: &str, tok: &str) -> Error {
260    Error::FormatRead {
261        format: FMT,
262        message: format!("field {key} {tok:?} is not a number"),
263    }
264}
265
266/// Field `key` as f64, defaulting to 0.0 when absent. Present but unparseable is
267/// a hard error: a malformed number must not silently become a plausible default
268/// and corrupt the matrices downstream.
269fn f(r: &Row, key: &str) -> Result<f64> {
270    f_or(r, key, 0.0)
271}
272/// Field `key` as f64, absent → `default`, present-but-unparseable → error.
273fn f_or(r: &Row, key: &str, default: f64) -> Result<f64> {
274    match r.get(key).copied() {
275        None | Some("") => Ok(default),
276        Some(s) => s.trim().parse().map_err(|_| bad_field(key, s)),
277    }
278}
279/// Field `key` as a bus id (parsed as f64 then truncated). Absent → 0;
280/// present-but-unparseable → error.
281fn uid(r: &Row, key: &str) -> Result<usize> {
282    match r.get(key).copied() {
283        None | Some("") => Ok(0),
284        // Parse through f64 (some exports print ids with a decimal point)
285        // but reject anything a float to integer cast would silently bend:
286        // NaN and negatives saturate to 0 and rewire the network, huge
287        // values to usize::MAX, fractions truncate.
288        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
289        Some(s) => match s.trim().parse::<f64>() {
290            Ok(v) if v.is_finite() && v.fract() == 0.0 && (0.0..=4_294_967_295.0).contains(&v) => {
291                Ok(v as usize)
292            }
293            _ => Err(bad_field(key, s)),
294        },
295    }
296}
297fn on(r: &Row, key: &str) -> Result<bool> {
298    // A closed vocabulary: an unrecognized status token must not silently
299    // mean energized (the same rule applies to numbers in f_or). Absent
300    // or empty keeps the documented in service default.
301    match r.get(key).copied().map(str::trim) {
302        None | Some("") => Ok(true),
303        Some(tok) if tok.eq_ignore_ascii_case("Closed") || tok == "1" => Ok(true),
304        Some(tok) if tok.eq_ignore_ascii_case("Open") || tok == "0" => Ok(false),
305        Some(tok) => Err(bad_field(key, tok)),
306    }
307}
308/// [`on`] over the first present field among `keys` (naming generations).
309fn on_alias(r: &Row, keys: &[&str]) -> Result<bool> {
310    match keys.iter().find(|k| r.contains_key(*k)) {
311        Some(k) => on(r, k),
312        None => Ok(true),
313    }
314}
315/// [`uid`] over the first present, non-empty field among `keys`.
316fn uid_alias(r: &Row, keys: &[&str]) -> Result<usize> {
317    match keys
318        .iter()
319        .find(|k| matches!(r.get(*k), Some(v) if !v.trim().is_empty()))
320    {
321        Some(k) => uid(r, k),
322        None => Ok(0),
323    }
324}
325
326fn bus_ref(
327    r: &Row,
328    num_keys: &[&str],
329    label_keys: &[&str],
330    bus_labels: &HashMap<&str, BusId>,
331) -> Result<BusId> {
332    let id = uid_alias(r, num_keys)?;
333    if id != 0 {
334        return Ok(BusId(id));
335    }
336    if let Some(label) = first(r, label_keys) {
337        return bus_labels
338            .get(label)
339            .copied()
340            .ok_or_else(|| Error::FormatRead {
341                format: FMT,
342                message: format!("unknown BusName_NomVolt label {label:?}"),
343            });
344    }
345    Err(Error::FormatRead {
346        format: FMT,
347        message: format!(
348            "row missing a bus key (expected one of {} or {})",
349            num_keys.join("/"),
350            label_keys.join("/")
351        ),
352    })
353}
354
355/// First present, non-empty field among `keys`, trimmed.
356fn first<'a>(r: &Row<'a>, keys: &[&str]) -> Option<&'a str> {
357    keys.iter()
358        .find_map(|k| r.get(k).copied())
359        .map(str::trim)
360        .filter(|v| !v.is_empty())
361}
362
363/// First present, non-empty field among `keys` as f64; absent → `default`.
364fn f_alias(r: &Row, keys: &[&str], default: f64) -> Result<f64> {
365    match keys
366        .iter()
367        .find(|k| matches!(r.get(*k), Some(v) if !v.trim().is_empty()))
368    {
369        Some(k) => f_or(r, k, default),
370        None => Ok(default),
371    }
372}
373
374/// Copy `keys` into `extras` verbatim (trimmed of the padding PowerWorld pads
375/// quoted values with), skipping absent or empty fields. The PowerWorld field
376/// name is the extras key, so the provenance is self describing and the writer
377/// can put the value back in the same field.
378fn keep_extras(r: &Row, keys: &[&str], extras: &mut Extras) {
379    for k in keys {
380        if let Some(v) = r.get(k) {
381            let v = v.trim();
382            if !v.is_empty() {
383                extras.insert((*k).to_string(), serde_json::Value::String(v.to_string()));
384            }
385        }
386    }
387}
388
389/// `BusCat` (our writer's vocabulary) when present; real exports carry
390/// `BusSlack` instead and the PV/PQ split is derived from the generators in
391/// [`derive_bus_kinds`].
392fn bus_kind(r: &Row) -> BusType {
393    match r.get("BusCat").copied().map(str::trim) {
394        Some("PV") => BusType::Pv,
395        Some("Slack") => BusType::Ref,
396        Some("Disconnected") => BusType::Isolated,
397        _ => {
398            if first(r, &["BusSlack", "Slack"]).is_some_and(|v| v.eq_ignore_ascii_case("YES")) {
399                BusType::Ref
400            } else {
401                BusType::Pq
402            }
403        }
404    }
405}
406
407/// PowerWorld stores no PV/PQ bus type; it follows from the machines. A bus
408/// with an in-service generator regulates voltage (PV) unless it is the slack.
409/// Only buses left at the PQ default are promoted, so an explicit `BusCat`
410/// from our own writer is never overridden.
411pub(super) fn derive_bus_kinds(buses: &mut [Bus], generators: &[Generator]) {
412    use std::collections::HashSet;
413    let gen_buses: HashSet<BusId> = generators
414        .iter()
415        .filter(|g| g.in_service)
416        .map(|g| g.bus)
417        .collect();
418    for b in buses {
419        if b.kind == BusType::Pq && gen_buses.contains(&b.id) {
420            b.kind = BusType::Pv;
421        }
422    }
423}
424
425fn read_bus(r: &Row) -> Result<Bus> {
426    let id = first(r, &["BusNum", "Number"])
427        .and_then(|v| v.parse::<f64>().ok())
428        .ok_or_else(|| Error::FormatRead {
429            format: FMT,
430            message: "Bus block row missing a numeric BusNum/Number".into(),
431        })? as usize;
432    let name = first(r, &["BusName", "Name"]).map(ToString::to_string);
433    let mut extras = Extras::new();
434    // Substation identity and coordinates ride on the bus row in complete
435    // case exports (`Latitude:1`/`Longitude:1` are the substation's).
436    keep_extras(
437        r,
438        &[
439            "SubNum",
440            "SubNumber",
441            "Latitude:1",
442            "Longitude:1",
443            "Latitude",
444            "Longitude",
445            "OwnerNum",
446            "OwnerNumber",
447            "BANumber",
448        ],
449        &mut extras,
450    );
451    Ok(Bus {
452        id: BusId(id),
453        kind: bus_kind(r),
454        vm: f_alias(r, &["BusPUVolt", "Vpu"], 1.0)?,
455        va: f_alias(r, &["BusAngle", "Vangle"], 0.0)?,
456        base_kv: f_alias(r, &["BusNomVolt", "NomkV"], 0.0)?,
457        // Real exports carry per rating set voltage limits; set 1 (set A in
458        // the 2022 vocabulary) is the default set. Our writer's
459        // BusVMax/BusVMin are the fallback aliases.
460        vmax: f_alias(r, &["BusVoltLimHigh:1", "LimitHighA", "BusVMax"], 1.1)?,
461        vmin: f_alias(r, &["BusVoltLimLow:1", "LimitLowA", "BusVMin"], 0.9)?,
462        evhi: None,
463        evlo: None,
464        area: uid_alias(r, &["AreaNum", "AreaNumber"])?,
465        zone: uid_alias(r, &["ZoneNum", "ZoneNumber"])?,
466        name,
467        uid: None,
468        extras,
469    })
470}
471
472fn read_load(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Load> {
473    // Complete case exports write ZIP components (constant power S, constant
474    // current I, constant impedance Z, each MW/MVAr at nominal voltage); the
475    // simple LoadMW/LoadMVR pair is our own writer's form. The typed model
476    // carries the total at nominal voltage; nonzero I/Z components are kept in
477    // extras so nothing about the voltage dependence is lost.
478    let (p, q);
479    let mut extras = Extras::new();
480    if r.contains_key("LoadMW") || r.contains_key("LoadMVR") {
481        p = f(r, "LoadMW")?;
482        q = f(r, "LoadMVR")?;
483    } else {
484        let smw = f_alias(r, &["LoadSMW", "SMW"], 0.0)?;
485        let imw = f_alias(r, &["LoadIMW", "IMW"], 0.0)?;
486        let zmw = f_alias(r, &["LoadZMW", "ZMW"], 0.0)?;
487        let smvr = f_alias(r, &["LoadSMVR", "SMvar"], 0.0)?;
488        let imvr = f_alias(r, &["LoadIMVR", "IMvar"], 0.0)?;
489        let zmvr = f_alias(r, &["LoadZMVR", "ZMvar"], 0.0)?;
490        p = smw + imw + zmw;
491        q = smvr + imvr + zmvr;
492        if imw != 0.0 || zmw != 0.0 || imvr != 0.0 || zmvr != 0.0 {
493            keep_extras(
494                r,
495                &[
496                    "LoadSMW", "LoadSMVR", "LoadIMW", "LoadIMVR", "LoadZMW", "LoadZMVR",
497                ],
498                &mut extras,
499            );
500        }
501    }
502    keep_extras(r, &["LoadID", "ID"], &mut extras);
503    Ok(Load {
504        bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
505        p,
506        q,
507        voltage_model: None,
508        in_service: on_alias(r, &["LoadStatus", "Status"])?,
509        uid: None,
510        extras,
511    })
512}
513
514fn read_shunt(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Shunt> {
515    let mut extras = Extras::new();
516    keep_extras(r, &["ShuntID", "ID", "SSCMode", "ShuntMode"], &mut extras);
517    Ok(Shunt {
518        bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
519        // Switched shunt nominal MW/MVAr in real exports (MWNom/MvarNom in
520        // the 2022 vocabulary); ShuntMW/ShuntMVR from our writer.
521        g: f_alias(r, &["ShuntMW", "SSNMW", "MWNom"], 0.0)?,
522        b: f_alias(r, &["ShuntMVR", "SSNMVR", "MvarNom"], 0.0)?,
523        in_service: on_alias(r, &["ShuntStatus", "SSStatus", "Status"])?,
524        control: None,
525        uid: None,
526        extras,
527    })
528}
529
530// `Generator` has no extras map (a deliberate parse-performance decision; see
531// the `GenCaps` doc), so GenID and the regulation fields are not retained on
532// the typed model. They stay reachable through the generic layer and survive
533// aux → aux via the retained source.
534fn read_gen(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Generator> {
535    Ok(Generator {
536        bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
537        // GenMW is the solved output; complete case exports write the
538        // dispatch setpoint instead.
539        pg: f_alias(r, &["GenMW", "GenMWSetPoint", "MWSetPoint"], 0.0)?,
540        qg: f_alias(r, &["GenMVR", "GenMvrSetPoint", "MvarSetPoint"], 0.0)?,
541        pmax: f_alias(r, &["GenMWMax", "MWMax"], 0.0)?,
542        pmin: f_alias(r, &["GenMWMin", "MWMin"], 0.0)?,
543        qmax: f_alias(r, &["GenMVRMax", "MvarMax"], 0.0)?,
544        qmin: f_alias(r, &["GenMVRMin", "MvarMin"], 0.0)?,
545        vg: f_alias(r, &["GenVoltSet", "VoltSet"], 1.0)?,
546        mbase: f_alias(r, &["GenMVABase", "MVABase"], 100.0)?,
547        in_service: on_alias(r, &["GenStatus", "Status"])?,
548        cost: None,
549        caps: Default::default(),
550        regulated_bus: None,
551        uid: None,
552    })
553}
554
555fn read_branch(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Branch> {
556    let is_xf = first(r, &[BRANCH_DEVICE_TYPE]).is_some_and(|v| v == "Transformer");
557    let mut extras = Extras::new();
558    // Branch identity beyond the bus pair: circuit ID and device type. Kept
559    // verbatim (PowerWorld pads circuit IDs) so aux → aux through the typed
560    // model reproduces them exactly.
561    if let Some(v) = r.get(LINE_CIRCUIT).or_else(|| r.get("Circuit")) {
562        extras.insert(
563            LINE_CIRCUIT.to_string(),
564            serde_json::Value::String((*v).to_string()),
565        );
566    }
567    keep_extras(r, &[BRANCH_DEVICE_TYPE, "LineLength"], &mut extras);
568    // Transformer records in complete case exports carry their impedance and
569    // tap under `:1` locations (values on the system base after correction);
570    // line records use the bare names. Our writer's LineXFRatio is the tap
571    // fallback.
572    // 2016 era exports use the bare name here like everywhere else.
573    let tap = f_alias(
574        r,
575        &["LineTap:1", "Tapxfbase", "LineXFRatio", "LineTap"],
576        1.0,
577    )?;
578    Ok(Branch {
579        from: bus_ref(
580            r,
581            &["BusNum", "BusNumFrom"],
582            &["BusName_NomVolt"],
583            bus_labels,
584        )?,
585        to: bus_ref(
586            r,
587            &["BusNum:1", "BusNumTo"],
588            &["BusName_NomVolt:1"],
589            bus_labels,
590        )?,
591        r: f_alias(r, &["LineR", "LineR:1", "R", "Rxfbase"], 0.0)?,
592        x: f_alias(r, &["LineX", "LineX:1", "X", "Xxfbase"], 0.0)?,
593        b: f_alias(r, &["LineC", "LineC:1", "B", "Bxfbase"], 0.0)?,
594        charging: None,
595        rate_a: f_alias(r, &["LineAMVA", "LimitMVAA"], 0.0)?,
596        rate_b: f_alias(r, &["LineAMVA:1", "LineBMVA", "LimitMVAB"], 0.0)?,
597        rate_c: f_alias(r, &["LineAMVA:2", "LineCMVA", "LimitMVAC"], 0.0)?,
598        rating_sets: Vec::new(),
599        current_ratings: None,
600        tap: if is_xf { tap } else { 0.0 },
601        shift: f_alias(r, &["LinePhase", "Phase"], 0.0)?,
602        in_service: on_alias(r, &["LineStatus", "Status"])?,
603        angmin: -360.0,
604        angmax: 360.0,
605        control: None,
606        solution: None,
607        uid: None,
608        extras,
609    })
610}
611
612// ---- Writer -----------------------------------------------------------------
613
614#[must_use]
615// A flat serializer: one section per PowerWorld object type; splitting it would
616// add indirection without clarity.
617#[expect(clippy::too_many_lines)]
618pub fn write_powerworld(net: &Network) -> Conversion {
619    let mut warnings = Vec::new();
620    let mut nonfinite = false;
621    let mut sanitized_names = 0usize;
622    let mut n = |x: f64| -> String {
623        if x.is_finite() {
624            format!("{x}")
625        } else {
626            nonfinite = true;
627            format!(
628                "{}",
629                if x > 0.0 {
630                    1.0e10
631                } else if x < 0.0 {
632                    -1.0e10
633                } else {
634                    0.0
635                }
636            )
637        }
638    };
639    let mut s = String::new();
640    let _ = writeln!(
641        s,
642        "// PowerWorld auxiliary file — powerio export: {}",
643        net.name
644    );
645    let _ = writeln!(s, "// baseMVA {}", net.base_mva);
646    let _ = writeln!(s);
647
648    block(
649        &mut s,
650        "Bus",
651        "[BusNum, BusName, BusNomVolt, BusPUVolt, BusAngle, AreaNum, ZoneNum, BusVMax, BusVMin, BusCat]",
652        |rows| {
653            for b in &net.buses {
654                let raw_name = b.name.as_deref().unwrap_or("");
655                let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
656                if matches!(name, std::borrow::Cow::Owned(_)) {
657                    sanitized_names += 1;
658                }
659                rows.push(format!(
660                    "{} \"{}\" {} {} {} {} {} {} {} \"{}\"",
661                    b.id,
662                    name,
663                    n(b.base_kv),
664                    n(b.vm),
665                    n(b.va),
666                    b.area,
667                    b.zone,
668                    n(b.vmax),
669                    n(b.vmin),
670                    bus_cat(b.kind)
671                ));
672            }
673        },
674    );
675
676    block(
677        &mut s,
678        "Load",
679        "[BusNum, LoadID, LoadMW, LoadMVR, LoadStatus]",
680        |rows| {
681            for (i, l) in net.loads.iter().enumerate() {
682                rows.push(format!(
683                    "{} \"{}\" {} {} \"{}\"",
684                    l.bus,
685                    id_of(&l.extras, "LoadID", i),
686                    n(l.p),
687                    n(l.q),
688                    status(l.in_service)
689                ));
690            }
691        },
692    );
693
694    block(
695        &mut s,
696        "Shunt",
697        "[BusNum, ShuntID, ShuntMW, ShuntMVR, ShuntStatus]",
698        |rows| {
699            for (i, sh) in net.shunts.iter().enumerate() {
700                rows.push(format!(
701                    "{} \"{}\" {} {} \"{}\"",
702                    sh.bus,
703                    id_of(&sh.extras, "ShuntID", i),
704                    n(sh.g),
705                    n(sh.b),
706                    status(sh.in_service)
707                ));
708            }
709        },
710    );
711
712    block(
713        &mut s,
714        "Gen",
715        "[BusNum, GenID, GenMW, GenMVR, GenMWMax, GenMWMin, GenMVRMax, GenMVRMin, GenVoltSet, GenMVABase, GenStatus]",
716        |rows| {
717            for (i, g) in net.generators.iter().enumerate() {
718                rows.push(format!(
719                    "{} \"{}\" {} {} {} {} {} {} {} {} \"{}\"",
720                    g.bus,
721                    i + 1,
722                    n(g.pg),
723                    n(g.qg),
724                    n(g.pmax),
725                    n(g.pmin),
726                    n(g.qmax),
727                    n(g.qmin),
728                    n(g.vg),
729                    n(g.mbase),
730                    status(g.in_service)
731                ));
732            }
733        },
734    );
735
736    block(
737        &mut s,
738        "Branch",
739        "[BusNum, BusNum:1, LineCircuit, LineR, LineX, LineC, LineAMVA, LineBMVA, LineCMVA, LineXFRatio, LinePhase, LineStatus, BranchDeviceType]",
740        |rows| {
741            // Parallel branches need distinct circuit IDs: the bus pair plus
742            // circuit is the PowerWorld branch identity, and a reader (ours
743            // included) treats equal identities as one device.
744            let mut parallel: HashMap<(BusId, BusId), u32> = HashMap::new();
745            for br in &net.branches {
746                let kind = match br.extras.get(BRANCH_DEVICE_TYPE).and_then(|v| v.as_str()) {
747                    Some(v) => v,
748                    None if br.is_transformer() => "Transformer",
749                    None => "Line",
750                };
751                let nth = parallel.entry((br.from, br.to)).or_insert(0);
752                *nth += 1;
753                let fallback = nth.to_string();
754                let circuit = br
755                    .extras
756                    .get(LINE_CIRCUIT)
757                    .and_then(|v| v.as_str())
758                    .unwrap_or(&fallback);
759                rows.push(format!(
760                    "{} {} \"{}\" {} {} {} {} {} {} {} {} \"{}\" \"{}\"",
761                    br.from,
762                    br.to,
763                    circuit,
764                    n(br.r),
765                    n(br.x),
766                    n(br.legacy_total_charging_b()),
767                    n(br.rate_a),
768                    n(br.rate_b),
769                    n(br.rate_c),
770                    n(br.effective_tap()),
771                    n(br.shift),
772                    status(br.in_service),
773                    kind
774                ));
775            }
776        },
777    );
778
779    if net.generators.iter().any(|g| g.cost.is_some()) {
780        warnings.push("generator cost curves dropped: not written to PowerWorld .aux".into());
781    }
782    if !net.hvdc.is_empty() {
783        warnings.push(format!(
784            "{} dcline(s) dropped: PowerWorld HVDC not modeled",
785            net.hvdc.len()
786        ));
787    }
788    if !net.transformers_3w.is_empty() {
789        warnings.push(format!(
790            "{} 3-winding transformer(s) dropped: the PowerWorld .aux writer emits no 3-winding record",
791            net.transformers_3w.len()
792        ));
793    }
794    if net
795        .buses
796        .iter()
797        .any(|b| b.evhi.is_some() || b.evlo.is_some())
798    {
799        warnings.push(
800            "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
801                .into(),
802        );
803    }
804    if !net.storage.is_empty() {
805        warnings.push(format!(
806            "{} storage unit(s) dropped: PowerWorld storage not modeled",
807            net.storage.len()
808        ));
809    }
810    let voltage_loads = net
811        .loads
812        .iter()
813        .filter(|l| {
814            l.voltage_model
815                .as_ref()
816                .is_some_and(LoadVoltageModel::has_non_matpower_fields)
817        })
818        .count();
819    if voltage_loads > 0 {
820        warnings.push(format!(
821            "{voltage_loads} voltage dependent load model(s) dropped: PowerWorld Load records carry static MW/MVR only"
822        ));
823    }
824    let terminal_charging = net
825        .branches
826        .iter()
827        .filter(|b| b.has_non_matpower_charging())
828        .count();
829    if terminal_charging > 0 {
830        warnings.push(format!(
831            "{terminal_charging} branch terminal admittance record(s) collapsed to total susceptance: PowerWorld aux branch rows written here cannot carry conductance or asymmetric terminal charging"
832        ));
833    }
834    let current_ratings = net
835        .branches
836        .iter()
837        .filter(|b| b.current_ratings.is_some())
838        .count();
839    if current_ratings > 0 {
840        warnings.push(format!(
841            "{current_ratings} branch current rating record(s) dropped: PowerWorld aux branch rows written here carry MVA ratings only"
842        ));
843    }
844    warn_extra_branch_rating_sets("PowerWorld .aux", net, &mut warnings);
845    let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
846    if branch_solutions > 0 {
847        warnings.push(format!(
848            "{branch_solutions} branch solution value set(s) dropped: PowerWorld aux result fields are not written"
849        ));
850    }
851    if net.branches.iter().any(Branch::has_angle_limits) {
852        warnings.push(
853            "branch angle limits (angmin/angmax) dropped: not written to PowerWorld .aux".into(),
854        );
855    }
856    if net.generators.iter().any(Generator::has_caps) {
857        warnings.push(
858            "generator ramp/capability columns dropped: not written to PowerWorld .aux".into(),
859        );
860    }
861    if nonfinite {
862        warnings.push("non-finite values written as ±1e10 sentinels".into());
863    }
864    if sanitized_names > 0 {
865        warnings.push(format!(
866            "{sanitized_names} bus name(s) contained a double quote that would corrupt a \
867             PowerWorld value; replaced with spaces"
868        ));
869    }
870
871    Conversion { text: s, warnings }
872}
873
874/// Device ID for the writer: the retained PowerWorld ID from `extras` when the
875/// element came from an aux read, else the 1-based position.
876fn id_of(extras: &Extras, key: &str, index: usize) -> String {
877    match extras.get(key).and_then(serde_json::Value::as_str) {
878        Some(v) => v.to_string(),
879        None => (index + 1).to_string(),
880    }
881}
882
883fn block(s: &mut String, object: &str, fields: &str, fill: impl FnOnce(&mut Vec<String>)) {
884    let mut rows = Vec::new();
885    fill(&mut rows);
886    let _ = writeln!(s, "DATA ({object}, {fields})");
887    let _ = writeln!(s, "{{");
888    for r in &rows {
889        let _ = writeln!(s, "  {r}");
890    }
891    let _ = writeln!(s, "}}");
892    let _ = writeln!(s);
893}
894
895fn status(on: bool) -> &'static str {
896    if on { "Closed" } else { "Open" }
897}
898
899fn bus_cat(kind: BusType) -> &'static str {
900    match kind {
901        BusType::Pq => "PQ",
902        BusType::Pv => "PV",
903        BusType::Ref => "Slack",
904        BusType::Isolated => "Disconnected",
905    }
906}