1use 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
23const NAME_FORBIDDEN: &[char] = &['"'];
26
27pub(super) const LINE_CIRCUIT: &str = "LineCircuit";
31pub(super) const BRANCH_DEVICE_TYPE: &str = "BranchDeviceType";
32
33#[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 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 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" => 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
166pub 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#[derive(PartialEq, Eq, Hash)]
188enum MergeKey<'a> {
189 Fields(Vec<&'a str>),
190 Ordinal(usize),
193}
194
195struct Merge<'a> {
196 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 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
268fn f(r: &Row, key: &str) -> Result<f64> {
272 f_or(r, key, 0.0)
273}
274fn 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}
281fn 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#[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 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 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}
325fn 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}
332fn 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
372fn 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
380fn 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
391fn 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
406fn 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
424pub(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 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 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 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 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
497fn 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 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 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
582fn 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 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 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 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#[must_use]
668#[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 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 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 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
947fn 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 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}