Skip to main content

powerio/format/powerworld/
map.rs

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