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 Branch, Bus, BusId, BusType, Extras, Generator, Load, LoadVoltageModel, Network, 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
33pub(crate) fn parse_powerworld_source(
39 source: Arc<String>,
40 name_hint: Option<&str>,
41 warnings: &mut Vec<String>,
42) -> Result<Network> {
43 let content: &str = &source;
44 let mut base_mva = 100.0;
50 let mut name = name_hint.unwrap_or("case").to_string();
51 for line in content.lines() {
52 let t = line.trim();
53 if let Some(rest) = t.strip_prefix("// baseMVA ") {
54 if let Ok(v) = rest.trim().parse::<f64>() {
55 base_mva = v;
56 }
57 } else if let Some((_, n)) = t.split_once("powerio export: ") {
58 name = n.trim().to_string();
59 }
60 }
61
62 let aux = parse_aux(content)?;
63 if aux.data().next().is_none() {
64 return Err(Error::FormatRead {
65 format: FMT,
66 message: "no DATA blocks found".into(),
67 });
68 }
69
70 let mut merged_buses = Merge::new(&[&["BusNum", "Number"]]);
78 let mut merged_loads = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["LoadID", "ID"]]);
79 let mut merged_shunts = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["ShuntID", "ID"]]);
80 let mut merged_gens = Merge::new(&[&["BusNum", "BusName_NomVolt"], &["GenID", "ID"]]);
81 let mut merged_branches = Merge::new(&[
82 &["BusNum", "BusNumFrom", "BusName_NomVolt"],
83 &["BusNum:1", "BusNumTo", "BusName_NomVolt:1"],
84 &[LINE_CIRCUIT, "Circuit"],
85 ]);
86 let mut unmodeled: BTreeMap<&str, usize> = BTreeMap::new();
87 for blk in aux.data() {
88 match blk.object_type.as_str() {
89 "Bus" => merged_buses.absorb(
90 blk,
91 blk.field_index("BusNum").is_some() || blk.field_index("Number").is_some(),
92 ),
93 "Load" => merged_loads.absorb(blk, true),
94 "Shunt" => merged_shunts.absorb(blk, true),
95 "Gen" => merged_gens.absorb(blk, true),
96 "Branch" => merged_branches.absorb(blk, true),
97 "Transformer" => merged_branches.absorb(blk, false),
101 _ => {
102 if !blk.rows.is_empty() {
103 *unmodeled.entry(&blk.object_type).or_default() += blk.rows.len();
104 }
105 }
106 }
107 }
108 warnings.extend(unmodeled.into_iter().map(|(object, rows)| {
109 format!(
110 "PowerWorld .aux DATA {object} has {rows} row(s) not modeled in Network; \
111 retained only in source text for same-format writeback"
112 )
113 }));
114
115 let mut buses = Vec::new();
116 let mut bus_labels = HashMap::new();
117 for r in merged_buses.rows() {
118 let bus = read_bus(r)?;
119 if let Some(label) = first(r, &["BusName_NomVolt"]) {
120 bus_labels.insert(label, bus.id);
121 }
122 buses.push(bus);
123 }
124 let mut loads = Vec::new();
125 for r in merged_loads.rows() {
126 loads.push(read_load(r, &bus_labels)?);
127 }
128 let mut shunts = Vec::new();
129 for r in merged_shunts.rows() {
130 shunts.push(read_shunt(r, &bus_labels)?);
131 }
132 let mut generators = Vec::new();
133 for r in merged_gens.rows() {
134 generators.push(read_gen(r, &bus_labels)?);
135 }
136 let mut branches = Vec::new();
137 for r in merged_branches.rows() {
138 branches.push(read_branch(r, &bus_labels)?);
139 }
140 derive_bus_kinds(&mut buses, &generators);
141
142 let net = Network {
143 name,
144 base_mva,
145 base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
146 buses,
147 loads,
148 shunts,
149 branches,
150 switches: Vec::new(),
151 generators,
152 storage: Vec::new(),
153 hvdc: Vec::new(),
154 transformers_3w: Vec::new(),
155 areas: Vec::new(),
156 solver: None,
157 source_format: SourceFormat::PowerWorld,
158 source: Some(source),
159 };
160 net.check_references(FMT)?;
161 Ok(net)
162}
163
164pub fn aux_sections(net: &Network) -> Option<Result<AuxFile>> {
174 if net.source_format != SourceFormat::PowerWorld {
175 return None;
176 }
177 net.source.as_ref().map(|s| parse_aux(s))
178}
179
180type Row<'a> = HashMap<&'a str, &'a str>;
181
182#[derive(PartialEq, Eq, Hash)]
186enum MergeKey<'a> {
187 Fields(Vec<&'a str>),
188 Ordinal(usize),
191}
192
193struct Merge<'a> {
194 key_fields: &'static [&'static [&'static str]],
198 index: HashMap<MergeKey<'a>, usize>,
199 merged: Vec<Row<'a>>,
200}
201
202impl<'a> Merge<'a> {
203 fn new(key_fields: &'static [&'static [&'static str]]) -> Self {
204 Merge {
205 key_fields,
206 index: HashMap::new(),
207 merged: Vec::new(),
208 }
209 }
210
211 fn absorb(&mut self, blk: &'a AuxObject, create: bool) {
215 let positions: Vec<Vec<usize>> = self
216 .key_fields
217 .iter()
218 .map(|group| group.iter().filter_map(|k| blk.field_index(k)).collect())
219 .collect();
220 let keyless = positions.iter().all(Vec::is_empty);
221 for (at, row) in blk.rows.iter().enumerate() {
222 let key = if keyless {
223 MergeKey::Ordinal(at)
224 } else {
225 MergeKey::Fields(
226 positions
227 .iter()
228 .map(|aliases| {
229 aliases
230 .iter()
231 .filter_map(|i| row.values.get(*i).map(|v| v.as_str().trim()))
232 .find(|v| !v.is_empty())
233 .unwrap_or("")
234 })
235 .collect(),
236 )
237 };
238 let slot = match self.index.get(&key) {
239 Some(&i) => i,
240 None if create => {
241 self.index.insert(key, self.merged.len());
242 self.merged.push(HashMap::with_capacity(blk.fields.len()));
243 self.merged.len() - 1
244 }
245 None => continue,
246 };
247 let entry = &mut self.merged[slot];
248 for (f, v) in blk.fields.iter().zip(&row.values) {
249 entry.insert(f.as_str(), v.as_str());
250 }
251 }
252 }
253
254 fn rows(&self) -> impl Iterator<Item = &Row<'a>> {
255 self.merged.iter()
256 }
257}
258
259fn bad_field(key: &str, tok: &str) -> Error {
260 Error::FormatRead {
261 format: FMT,
262 message: format!("field {key} {tok:?} is not a number"),
263 }
264}
265
266fn f(r: &Row, key: &str) -> Result<f64> {
270 f_or(r, key, 0.0)
271}
272fn f_or(r: &Row, key: &str, default: f64) -> Result<f64> {
274 match r.get(key).copied() {
275 None | Some("") => Ok(default),
276 Some(s) => s.trim().parse().map_err(|_| bad_field(key, s)),
277 }
278}
279fn uid(r: &Row, key: &str) -> Result<usize> {
282 match r.get(key).copied() {
283 None | Some("") => Ok(0),
284 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
289 Some(s) => match s.trim().parse::<f64>() {
290 Ok(v) if v.is_finite() && v.fract() == 0.0 && (0.0..=4_294_967_295.0).contains(&v) => {
291 Ok(v as usize)
292 }
293 _ => Err(bad_field(key, s)),
294 },
295 }
296}
297fn on(r: &Row, key: &str) -> Result<bool> {
298 match r.get(key).copied().map(str::trim) {
302 None | Some("") => Ok(true),
303 Some(tok) if tok.eq_ignore_ascii_case("Closed") || tok == "1" => Ok(true),
304 Some(tok) if tok.eq_ignore_ascii_case("Open") || tok == "0" => Ok(false),
305 Some(tok) => Err(bad_field(key, tok)),
306 }
307}
308fn on_alias(r: &Row, keys: &[&str]) -> Result<bool> {
310 match keys.iter().find(|k| r.contains_key(*k)) {
311 Some(k) => on(r, k),
312 None => Ok(true),
313 }
314}
315fn uid_alias(r: &Row, keys: &[&str]) -> Result<usize> {
317 match keys
318 .iter()
319 .find(|k| matches!(r.get(*k), Some(v) if !v.trim().is_empty()))
320 {
321 Some(k) => uid(r, k),
322 None => Ok(0),
323 }
324}
325
326fn bus_ref(
327 r: &Row,
328 num_keys: &[&str],
329 label_keys: &[&str],
330 bus_labels: &HashMap<&str, BusId>,
331) -> Result<BusId> {
332 let id = uid_alias(r, num_keys)?;
333 if id != 0 {
334 return Ok(BusId(id));
335 }
336 if let Some(label) = first(r, label_keys) {
337 return bus_labels
338 .get(label)
339 .copied()
340 .ok_or_else(|| Error::FormatRead {
341 format: FMT,
342 message: format!("unknown BusName_NomVolt label {label:?}"),
343 });
344 }
345 Err(Error::FormatRead {
346 format: FMT,
347 message: format!(
348 "row missing a bus key (expected one of {} or {})",
349 num_keys.join("/"),
350 label_keys.join("/")
351 ),
352 })
353}
354
355fn first<'a>(r: &Row<'a>, keys: &[&str]) -> Option<&'a str> {
357 keys.iter()
358 .find_map(|k| r.get(k).copied())
359 .map(str::trim)
360 .filter(|v| !v.is_empty())
361}
362
363fn f_alias(r: &Row, keys: &[&str], default: f64) -> Result<f64> {
365 match keys
366 .iter()
367 .find(|k| matches!(r.get(*k), Some(v) if !v.trim().is_empty()))
368 {
369 Some(k) => f_or(r, k, default),
370 None => Ok(default),
371 }
372}
373
374fn keep_extras(r: &Row, keys: &[&str], extras: &mut Extras) {
379 for k in keys {
380 if let Some(v) = r.get(k) {
381 let v = v.trim();
382 if !v.is_empty() {
383 extras.insert((*k).to_string(), serde_json::Value::String(v.to_string()));
384 }
385 }
386 }
387}
388
389fn bus_kind(r: &Row) -> BusType {
393 match r.get("BusCat").copied().map(str::trim) {
394 Some("PV") => BusType::Pv,
395 Some("Slack") => BusType::Ref,
396 Some("Disconnected") => BusType::Isolated,
397 _ => {
398 if first(r, &["BusSlack", "Slack"]).is_some_and(|v| v.eq_ignore_ascii_case("YES")) {
399 BusType::Ref
400 } else {
401 BusType::Pq
402 }
403 }
404 }
405}
406
407pub(super) fn derive_bus_kinds(buses: &mut [Bus], generators: &[Generator]) {
412 use std::collections::HashSet;
413 let gen_buses: HashSet<BusId> = generators
414 .iter()
415 .filter(|g| g.in_service)
416 .map(|g| g.bus)
417 .collect();
418 for b in buses {
419 if b.kind == BusType::Pq && gen_buses.contains(&b.id) {
420 b.kind = BusType::Pv;
421 }
422 }
423}
424
425fn read_bus(r: &Row) -> Result<Bus> {
426 let id = first(r, &["BusNum", "Number"])
427 .and_then(|v| v.parse::<f64>().ok())
428 .ok_or_else(|| Error::FormatRead {
429 format: FMT,
430 message: "Bus block row missing a numeric BusNum/Number".into(),
431 })? as usize;
432 let name = first(r, &["BusName", "Name"]).map(ToString::to_string);
433 let mut extras = Extras::new();
434 keep_extras(
437 r,
438 &[
439 "SubNum",
440 "SubNumber",
441 "Latitude:1",
442 "Longitude:1",
443 "Latitude",
444 "Longitude",
445 "OwnerNum",
446 "OwnerNumber",
447 "BANumber",
448 ],
449 &mut extras,
450 );
451 Ok(Bus {
452 id: BusId(id),
453 kind: bus_kind(r),
454 vm: f_alias(r, &["BusPUVolt", "Vpu"], 1.0)?,
455 va: f_alias(r, &["BusAngle", "Vangle"], 0.0)?,
456 base_kv: f_alias(r, &["BusNomVolt", "NomkV"], 0.0)?,
457 vmax: f_alias(r, &["BusVoltLimHigh:1", "LimitHighA", "BusVMax"], 1.1)?,
461 vmin: f_alias(r, &["BusVoltLimLow:1", "LimitLowA", "BusVMin"], 0.9)?,
462 evhi: None,
463 evlo: None,
464 area: uid_alias(r, &["AreaNum", "AreaNumber"])?,
465 zone: uid_alias(r, &["ZoneNum", "ZoneNumber"])?,
466 name,
467 uid: None,
468 extras,
469 })
470}
471
472fn read_load(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Load> {
473 let (p, q);
479 let mut extras = Extras::new();
480 if r.contains_key("LoadMW") || r.contains_key("LoadMVR") {
481 p = f(r, "LoadMW")?;
482 q = f(r, "LoadMVR")?;
483 } else {
484 let smw = f_alias(r, &["LoadSMW", "SMW"], 0.0)?;
485 let imw = f_alias(r, &["LoadIMW", "IMW"], 0.0)?;
486 let zmw = f_alias(r, &["LoadZMW", "ZMW"], 0.0)?;
487 let smvr = f_alias(r, &["LoadSMVR", "SMvar"], 0.0)?;
488 let imvr = f_alias(r, &["LoadIMVR", "IMvar"], 0.0)?;
489 let zmvr = f_alias(r, &["LoadZMVR", "ZMvar"], 0.0)?;
490 p = smw + imw + zmw;
491 q = smvr + imvr + zmvr;
492 if imw != 0.0 || zmw != 0.0 || imvr != 0.0 || zmvr != 0.0 {
493 keep_extras(
494 r,
495 &[
496 "LoadSMW", "LoadSMVR", "LoadIMW", "LoadIMVR", "LoadZMW", "LoadZMVR",
497 ],
498 &mut extras,
499 );
500 }
501 }
502 keep_extras(r, &["LoadID", "ID"], &mut extras);
503 Ok(Load {
504 bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
505 p,
506 q,
507 voltage_model: None,
508 in_service: on_alias(r, &["LoadStatus", "Status"])?,
509 uid: None,
510 extras,
511 })
512}
513
514fn read_shunt(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Shunt> {
515 let mut extras = Extras::new();
516 keep_extras(r, &["ShuntID", "ID", "SSCMode", "ShuntMode"], &mut extras);
517 Ok(Shunt {
518 bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
519 g: f_alias(r, &["ShuntMW", "SSNMW", "MWNom"], 0.0)?,
522 b: f_alias(r, &["ShuntMVR", "SSNMVR", "MvarNom"], 0.0)?,
523 in_service: on_alias(r, &["ShuntStatus", "SSStatus", "Status"])?,
524 control: None,
525 uid: None,
526 extras,
527 })
528}
529
530fn read_gen(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Generator> {
535 Ok(Generator {
536 bus: bus_ref(r, &["BusNum"], &["BusName_NomVolt"], bus_labels)?,
537 pg: f_alias(r, &["GenMW", "GenMWSetPoint", "MWSetPoint"], 0.0)?,
540 qg: f_alias(r, &["GenMVR", "GenMvrSetPoint", "MvarSetPoint"], 0.0)?,
541 pmax: f_alias(r, &["GenMWMax", "MWMax"], 0.0)?,
542 pmin: f_alias(r, &["GenMWMin", "MWMin"], 0.0)?,
543 qmax: f_alias(r, &["GenMVRMax", "MvarMax"], 0.0)?,
544 qmin: f_alias(r, &["GenMVRMin", "MvarMin"], 0.0)?,
545 vg: f_alias(r, &["GenVoltSet", "VoltSet"], 1.0)?,
546 mbase: f_alias(r, &["GenMVABase", "MVABase"], 100.0)?,
547 in_service: on_alias(r, &["GenStatus", "Status"])?,
548 cost: None,
549 caps: Default::default(),
550 regulated_bus: None,
551 uid: None,
552 })
553}
554
555fn read_branch(r: &Row, bus_labels: &HashMap<&str, BusId>) -> Result<Branch> {
556 let is_xf = first(r, &[BRANCH_DEVICE_TYPE]).is_some_and(|v| v == "Transformer");
557 let mut extras = Extras::new();
558 if let Some(v) = r.get(LINE_CIRCUIT).or_else(|| r.get("Circuit")) {
562 extras.insert(
563 LINE_CIRCUIT.to_string(),
564 serde_json::Value::String((*v).to_string()),
565 );
566 }
567 keep_extras(r, &[BRANCH_DEVICE_TYPE, "LineLength"], &mut extras);
568 let tap = f_alias(
574 r,
575 &["LineTap:1", "Tapxfbase", "LineXFRatio", "LineTap"],
576 1.0,
577 )?;
578 Ok(Branch {
579 from: bus_ref(
580 r,
581 &["BusNum", "BusNumFrom"],
582 &["BusName_NomVolt"],
583 bus_labels,
584 )?,
585 to: bus_ref(
586 r,
587 &["BusNum:1", "BusNumTo"],
588 &["BusName_NomVolt:1"],
589 bus_labels,
590 )?,
591 r: f_alias(r, &["LineR", "LineR:1", "R", "Rxfbase"], 0.0)?,
592 x: f_alias(r, &["LineX", "LineX:1", "X", "Xxfbase"], 0.0)?,
593 b: f_alias(r, &["LineC", "LineC:1", "B", "Bxfbase"], 0.0)?,
594 charging: None,
595 rate_a: f_alias(r, &["LineAMVA", "LimitMVAA"], 0.0)?,
596 rate_b: f_alias(r, &["LineAMVA:1", "LineBMVA", "LimitMVAB"], 0.0)?,
597 rate_c: f_alias(r, &["LineAMVA:2", "LineCMVA", "LimitMVAC"], 0.0)?,
598 rating_sets: Vec::new(),
599 current_ratings: None,
600 tap: if is_xf { tap } else { 0.0 },
601 shift: f_alias(r, &["LinePhase", "Phase"], 0.0)?,
602 in_service: on_alias(r, &["LineStatus", "Status"])?,
603 angmin: -360.0,
604 angmax: 360.0,
605 control: None,
606 solution: None,
607 uid: None,
608 extras,
609 })
610}
611
612#[must_use]
615#[expect(clippy::too_many_lines)]
618pub fn write_powerworld(net: &Network) -> Conversion {
619 let mut warnings = Vec::new();
620 let mut nonfinite = false;
621 let mut sanitized_names = 0usize;
622 let mut n = |x: f64| -> String {
623 if x.is_finite() {
624 format!("{x}")
625 } else {
626 nonfinite = true;
627 format!(
628 "{}",
629 if x > 0.0 {
630 1.0e10
631 } else if x < 0.0 {
632 -1.0e10
633 } else {
634 0.0
635 }
636 )
637 }
638 };
639 let mut s = String::new();
640 let _ = writeln!(
641 s,
642 "// PowerWorld auxiliary file — powerio export: {}",
643 net.name
644 );
645 let _ = writeln!(s, "// baseMVA {}", net.base_mva);
646 let _ = writeln!(s);
647
648 block(
649 &mut s,
650 "Bus",
651 "[BusNum, BusName, BusNomVolt, BusPUVolt, BusAngle, AreaNum, ZoneNum, BusVMax, BusVMin, BusCat]",
652 |rows| {
653 for b in &net.buses {
654 let raw_name = b.name.as_deref().unwrap_or("");
655 let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
656 if matches!(name, std::borrow::Cow::Owned(_)) {
657 sanitized_names += 1;
658 }
659 rows.push(format!(
660 "{} \"{}\" {} {} {} {} {} {} {} \"{}\"",
661 b.id,
662 name,
663 n(b.base_kv),
664 n(b.vm),
665 n(b.va),
666 b.area,
667 b.zone,
668 n(b.vmax),
669 n(b.vmin),
670 bus_cat(b.kind)
671 ));
672 }
673 },
674 );
675
676 block(
677 &mut s,
678 "Load",
679 "[BusNum, LoadID, LoadMW, LoadMVR, LoadStatus]",
680 |rows| {
681 for (i, l) in net.loads.iter().enumerate() {
682 rows.push(format!(
683 "{} \"{}\" {} {} \"{}\"",
684 l.bus,
685 id_of(&l.extras, "LoadID", i),
686 n(l.p),
687 n(l.q),
688 status(l.in_service)
689 ));
690 }
691 },
692 );
693
694 block(
695 &mut s,
696 "Shunt",
697 "[BusNum, ShuntID, ShuntMW, ShuntMVR, ShuntStatus]",
698 |rows| {
699 for (i, sh) in net.shunts.iter().enumerate() {
700 rows.push(format!(
701 "{} \"{}\" {} {} \"{}\"",
702 sh.bus,
703 id_of(&sh.extras, "ShuntID", i),
704 n(sh.g),
705 n(sh.b),
706 status(sh.in_service)
707 ));
708 }
709 },
710 );
711
712 block(
713 &mut s,
714 "Gen",
715 "[BusNum, GenID, GenMW, GenMVR, GenMWMax, GenMWMin, GenMVRMax, GenMVRMin, GenVoltSet, GenMVABase, GenStatus]",
716 |rows| {
717 for (i, g) in net.generators.iter().enumerate() {
718 rows.push(format!(
719 "{} \"{}\" {} {} {} {} {} {} {} {} \"{}\"",
720 g.bus,
721 i + 1,
722 n(g.pg),
723 n(g.qg),
724 n(g.pmax),
725 n(g.pmin),
726 n(g.qmax),
727 n(g.qmin),
728 n(g.vg),
729 n(g.mbase),
730 status(g.in_service)
731 ));
732 }
733 },
734 );
735
736 block(
737 &mut s,
738 "Branch",
739 "[BusNum, BusNum:1, LineCircuit, LineR, LineX, LineC, LineAMVA, LineBMVA, LineCMVA, LineXFRatio, LinePhase, LineStatus, BranchDeviceType]",
740 |rows| {
741 let mut parallel: HashMap<(BusId, BusId), u32> = HashMap::new();
745 for br in &net.branches {
746 let kind = match br.extras.get(BRANCH_DEVICE_TYPE).and_then(|v| v.as_str()) {
747 Some(v) => v,
748 None if br.is_transformer() => "Transformer",
749 None => "Line",
750 };
751 let nth = parallel.entry((br.from, br.to)).or_insert(0);
752 *nth += 1;
753 let fallback = nth.to_string();
754 let circuit = br
755 .extras
756 .get(LINE_CIRCUIT)
757 .and_then(|v| v.as_str())
758 .unwrap_or(&fallback);
759 rows.push(format!(
760 "{} {} \"{}\" {} {} {} {} {} {} {} {} \"{}\" \"{}\"",
761 br.from,
762 br.to,
763 circuit,
764 n(br.r),
765 n(br.x),
766 n(br.legacy_total_charging_b()),
767 n(br.rate_a),
768 n(br.rate_b),
769 n(br.rate_c),
770 n(br.effective_tap()),
771 n(br.shift),
772 status(br.in_service),
773 kind
774 ));
775 }
776 },
777 );
778
779 if net.generators.iter().any(|g| g.cost.is_some()) {
780 warnings.push("generator cost curves dropped: not written to PowerWorld .aux".into());
781 }
782 if !net.hvdc.is_empty() {
783 warnings.push(format!(
784 "{} dcline(s) dropped: PowerWorld HVDC not modeled",
785 net.hvdc.len()
786 ));
787 }
788 if !net.transformers_3w.is_empty() {
789 warnings.push(format!(
790 "{} 3-winding transformer(s) dropped: the PowerWorld .aux writer emits no 3-winding record",
791 net.transformers_3w.len()
792 ));
793 }
794 if net
795 .buses
796 .iter()
797 .any(|b| b.evhi.is_some() || b.evlo.is_some())
798 {
799 warnings.push(
800 "emergency voltage band(s) (EVHI/EVLO) dropped: this writer carries one voltage band"
801 .into(),
802 );
803 }
804 if !net.storage.is_empty() {
805 warnings.push(format!(
806 "{} storage unit(s) dropped: PowerWorld storage not modeled",
807 net.storage.len()
808 ));
809 }
810 let voltage_loads = net
811 .loads
812 .iter()
813 .filter(|l| {
814 l.voltage_model
815 .as_ref()
816 .is_some_and(LoadVoltageModel::has_non_matpower_fields)
817 })
818 .count();
819 if voltage_loads > 0 {
820 warnings.push(format!(
821 "{voltage_loads} voltage dependent load model(s) dropped: PowerWorld Load records carry static MW/MVR only"
822 ));
823 }
824 let terminal_charging = net
825 .branches
826 .iter()
827 .filter(|b| b.has_non_matpower_charging())
828 .count();
829 if terminal_charging > 0 {
830 warnings.push(format!(
831 "{terminal_charging} branch terminal admittance record(s) collapsed to total susceptance: PowerWorld aux branch rows written here cannot carry conductance or asymmetric terminal charging"
832 ));
833 }
834 let current_ratings = net
835 .branches
836 .iter()
837 .filter(|b| b.current_ratings.is_some())
838 .count();
839 if current_ratings > 0 {
840 warnings.push(format!(
841 "{current_ratings} branch current rating record(s) dropped: PowerWorld aux branch rows written here carry MVA ratings only"
842 ));
843 }
844 warn_extra_branch_rating_sets("PowerWorld .aux", net, &mut warnings);
845 let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
846 if branch_solutions > 0 {
847 warnings.push(format!(
848 "{branch_solutions} branch solution value set(s) dropped: PowerWorld aux result fields are not written"
849 ));
850 }
851 if net.branches.iter().any(Branch::has_angle_limits) {
852 warnings.push(
853 "branch angle limits (angmin/angmax) dropped: not written to PowerWorld .aux".into(),
854 );
855 }
856 if net.generators.iter().any(Generator::has_caps) {
857 warnings.push(
858 "generator ramp/capability columns dropped: not written to PowerWorld .aux".into(),
859 );
860 }
861 if nonfinite {
862 warnings.push("non-finite values written as ±1e10 sentinels".into());
863 }
864 if sanitized_names > 0 {
865 warnings.push(format!(
866 "{sanitized_names} bus name(s) contained a double quote that would corrupt a \
867 PowerWorld value; replaced with spaces"
868 ));
869 }
870
871 Conversion { text: s, warnings }
872}
873
874fn id_of(extras: &Extras, key: &str, index: usize) -> String {
877 match extras.get(key).and_then(serde_json::Value::as_str) {
878 Some(v) => v.to_string(),
879 None => (index + 1).to_string(),
880 }
881}
882
883fn block(s: &mut String, object: &str, fields: &str, fill: impl FnOnce(&mut Vec<String>)) {
884 let mut rows = Vec::new();
885 fill(&mut rows);
886 let _ = writeln!(s, "DATA ({object}, {fields})");
887 let _ = writeln!(s, "{{");
888 for r in &rows {
889 let _ = writeln!(s, " {r}");
890 }
891 let _ = writeln!(s, "}}");
892 let _ = writeln!(s);
893}
894
895fn status(on: bool) -> &'static str {
896 if on { "Closed" } else { "Open" }
897}
898
899fn bus_cat(kind: BusType) -> &'static str {
900 match kind {
901 BusType::Pq => "PQ",
902 BusType::Pv => "PV",
903 BusType::Ref => "Slack",
904 BusType::Isolated => "Disconnected",
905 }
906}