Skip to main content

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