1use 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
24const NAME_FORBIDDEN: &[char] = &['"'];
27
28pub(super) const LINE_CIRCUIT: &str = "LineCircuit";
32pub(super) const BRANCH_DEVICE_TYPE: &str = "BranchDeviceType";
33const 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";
41const GEN_IO_COEFFICIENTS: usize = 4;
44
45#[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 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 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 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" => 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
189pub 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#[derive(PartialEq, Eq, Hash)]
208enum MergeKey<'a> {
209 Fields(Vec<&'a str>),
210 Ordinal(usize),
213}
214
215struct Merge<'a> {
216 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 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
288fn f(r: &Row, key: &str) -> Result<f64> {
292 f_or(r, key, 0.0)
293}
294fn 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}
301fn 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#[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 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 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}
345fn 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}
352fn 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
392fn 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
400fn 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
411fn 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
426fn 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
444pub(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 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 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 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 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
517fn 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 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 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
605fn 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 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
634fn 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 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 super::drop_positional_id(&mut extras, &[LINE_CIRCUIT], *nth as usize - 1);
698 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 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#[must_use]
752#[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 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 let write_locations = net.buses().iter().any(|b| b.location.is_some());
792 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 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 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 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 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 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
1152fn 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 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}