1use std::collections::{HashMap, HashSet};
16
17use crate::islands::{
18 IslandPartition, IslandReferencePolicy, bus_list, kept_reference, largest_generator_bus,
19};
20use crate::merge::{BusMerge, BusMergeRule};
21use crate::network::{
22 BalancedNetwork, BalancedNetworkTables, Branch, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost,
23 Generator, Hvdc, Load, LoadVoltageModel, Shunt, SourceFormat, StaticVarCompensator, Storage,
24 Switch, Transformer3W, TransformerControl, TransformerControlMode,
25};
26use crate::{Error, Result};
27
28pub(crate) const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
31
32pub(crate) const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
35
36fn norm_transformer_control(
37 control: &mut TransformerControl,
38 base_mva: f64,
39 map: &HashMap<BusId, BusId>,
40) {
41 control.controlled_bus = control
42 .controlled_bus
43 .and_then(|controlled_bus| remap(map, controlled_bus));
44 if matches!(
48 control.mode,
49 TransformerControlMode::ActiveFlow | TransformerControlMode::AsymmetricActiveFlow
50 ) {
51 control.tap_min *= DEG_TO_RAD;
52 control.tap_max *= DEG_TO_RAD;
53 }
54 if matches!(
57 control.mode,
58 TransformerControlMode::ReactiveFlow
59 | TransformerControlMode::ActiveFlow
60 | TransformerControlMode::AsymmetricActiveFlow
61 ) {
62 control.band_min /= base_mva;
63 control.band_max /= base_mva;
64 }
65 if let Some(angle) = &mut control.winding_connection_angle {
66 *angle *= DEG_TO_RAD;
67 }
68}
69
70pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
75
76#[allow(clippy::approx_constant)]
78pub const POWER_MODELS_ANGLE_BOUND_PAD: f64 = 1.0472;
79
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum ClosedSwitchPolicy {
84 #[default]
93 Merge,
94 Refuse,
97 Keep,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct NormalizeOptions {
105 pub clamp_angle_bounds: bool,
108 pub angle_bound_pad: f64,
110}
111
112impl Default for NormalizeOptions {
113 fn default() -> Self {
114 Self {
115 clamp_angle_bounds: false,
116 angle_bound_pad: POWER_MODELS_ANGLE_BOUND_PAD,
117 }
118 }
119}
120
121#[derive(Clone, Copy, Debug, Default, PartialEq)]
126#[non_exhaustive]
127pub struct NormalizeSettings {
128 pub options: NormalizeOptions,
130 pub closed_switches: ClosedSwitchPolicy,
132 pub island_references: IslandReferencePolicy,
138}
139
140impl NormalizeSettings {
141 #[must_use]
143 pub const fn new(options: NormalizeOptions) -> Self {
144 Self {
145 options,
146 closed_switches: ClosedSwitchPolicy::Merge,
147 island_references: IslandReferencePolicy::Stated,
148 }
149 }
150
151 #[must_use]
152 pub const fn with_options(mut self, options: NormalizeOptions) -> Self {
153 self.options = options;
154 self
155 }
156
157 #[must_use]
158 pub const fn with_closed_switches(mut self, policy: ClosedSwitchPolicy) -> Self {
159 self.closed_switches = policy;
160 self
161 }
162
163 #[must_use]
164 pub const fn with_island_references(mut self, policy: IslandReferencePolicy) -> Self {
165 self.island_references = policy;
166 self
167 }
168}
169
170impl From<NormalizeOptions> for NormalizeSettings {
171 fn from(options: NormalizeOptions) -> Self {
172 Self::new(options)
173 }
174}
175
176#[derive(Clone, Debug)]
178pub struct NormalizedNetwork {
179 pub network: BalancedNetwork,
180 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
182 pub warnings: Vec<String>,
184}
185
186#[derive(Clone, Debug, Default)]
189#[non_exhaustive]
190pub struct NormalizeReport {
191 pub bus_merge: Option<BusMerge>,
196}
197
198#[derive(Clone, Debug)]
220#[non_exhaustive]
221pub struct NormalizeSourceRows {
222 pub buses: Vec<Option<usize>>,
223 pub loads: Vec<Option<usize>>,
224 pub shunts: Vec<Option<usize>>,
225 pub static_var_compensators: Vec<Option<usize>>,
226 pub branches: Vec<Option<usize>>,
227 pub switches: Vec<Option<usize>>,
228 pub generators: Vec<Option<usize>>,
229 pub storage: Vec<Option<usize>>,
230 pub hvdc: Vec<Option<usize>>,
231 pub transformers_3w: Vec<Option<usize>>,
232}
233
234impl NormalizeSourceRows {
235 fn through_merge(mut self, merge: &BusMerge) -> Self {
240 let source_bus: HashMap<BusId, usize> = merge
241 .source
242 .buses()
243 .iter()
244 .enumerate()
245 .map(|(row, bus)| (bus.id, row))
246 .collect();
247 let merged_buses = merge.network.buses();
248 for row in self.buses.iter_mut().flatten() {
249 *row = source_bus[&merged_buses[*row].id];
250 }
251 let inverse = |rows: &[Option<usize>]| {
252 let mut inverse = vec![0; rows.iter().flatten().count()];
253 for (source, merged) in rows.iter().enumerate() {
254 if let Some(merged) = merged {
255 inverse[*merged] = source;
256 }
257 }
258 inverse
259 };
260 let branches = inverse(&merge.branch_rows);
261 for row in self.branches.iter_mut().flatten() {
262 *row = branches[*row];
263 }
264 let switches = inverse(&merge.switch_rows);
265 for row in self.switches.iter_mut().flatten() {
266 *row = switches[*row];
267 }
268 let source_shunts = merge.source.shunts().len();
269 for row in &mut self.shunts {
270 if row.is_some_and(|row| row >= source_shunts) {
271 *row = None;
272 }
273 }
274 self
275 }
276
277 pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
282 let lengths = net.lowered_lengths();
283 self.buses.resize(lengths.buses, None);
284 self.branches.resize(lengths.branches, None);
285 self.shunts.resize(lengths.shunts, None);
286 }
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290enum CostModel {
291 Piecewise,
292 Polynomial,
293 Unknown,
294}
295
296impl From<u8> for CostModel {
297 fn from(value: u8) -> Self {
298 match value {
299 1 => CostModel::Piecewise,
300 2 => CostModel::Polynomial,
301 _ => CostModel::Unknown,
302 }
303 }
304}
305
306pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
318 let mut coeffs = cost.coeffs.clone();
319 scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
320 coeffs
321}
322
323pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
326 match CostModel::from(model) {
327 CostModel::Polynomial => {
328 coeffs.truncate(ncost.min(coeffs.len()));
329 let k = coeffs.len();
330 for (i, c) in coeffs.iter_mut().enumerate() {
333 *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
334 }
335 }
336 CostModel::Piecewise => {
337 coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
341 for c in coeffs.iter_mut().step_by(2) {
342 *c /= base;
343 }
344 }
345 CostModel::Unknown => {}
346 }
347}
348
349pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
355 let k = coeffs.len();
356 match CostModel::from(model) {
357 CostModel::Polynomial => coeffs
358 .iter()
359 .enumerate()
360 .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
361 .collect(),
362 CostModel::Piecewise => coeffs
363 .iter()
364 .enumerate()
365 .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
366 .collect(),
367 CostModel::Unknown => coeffs.to_vec(),
368 }
369}
370
371fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
373 map.get(&id).copied()
374}
375
376fn norm_loads(
377 loads: &[Load],
378 base: f64,
379 map: &HashMap<BusId, BusId>,
380) -> (Vec<Load>, Vec<Option<usize>>) {
381 loads
382 .iter()
383 .enumerate()
384 .filter(|(_, l)| l.in_service)
385 .filter_map(|(row, l)| {
386 Some((
387 Load {
388 bus: remap(map, l.bus)?,
389 p: l.p / base,
390 q: l.q / base,
391 voltage_model: l
392 .voltage_model
393 .as_ref()
394 .map(|m| norm_load_voltage_model(m, base)),
395 ..l.clone()
396 },
397 Some(row),
398 ))
399 })
400 .unzip()
401}
402
403fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
404 match model {
405 LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
406 LoadVoltageModel::Zip {
407 p_constant_power,
408 q_constant_power,
409 p_constant_current,
410 q_constant_current,
411 p_constant_impedance,
412 q_constant_impedance,
413 v_nom,
414 load_type,
415 scaling,
416 } => LoadVoltageModel::Zip {
417 p_constant_power: p_constant_power / base,
418 q_constant_power: q_constant_power / base,
419 p_constant_current: p_constant_current / base,
420 q_constant_current: q_constant_current / base,
421 p_constant_impedance: p_constant_impedance / base,
422 q_constant_impedance: q_constant_impedance / base,
423 v_nom: *v_nom,
424 load_type: *load_type,
425 scaling: *scaling,
426 },
427 LoadVoltageModel::Exponential {
428 p,
429 q,
430 v_nom,
431 gamma_p,
432 gamma_q,
433 } => LoadVoltageModel::Exponential {
434 p: p / base,
435 q: q / base,
436 v_nom: *v_nom,
437 gamma_p: *gamma_p,
438 gamma_q: *gamma_q,
439 },
440 }
441}
442
443fn norm_shunts(
444 shunts: &[Shunt],
445 base: f64,
446 map: &HashMap<BusId, BusId>,
447) -> (Vec<Shunt>, Vec<Option<usize>>) {
448 shunts
449 .iter()
450 .enumerate()
451 .filter(|(_, s)| s.in_service)
452 .filter_map(|(row, s)| {
453 let mut shunt = s.clone();
454 shunt.bus = remap(map, s.bus)?;
455 shunt.g = s.g / base;
456 shunt.b = s.b / base;
457 if let Some(c) = &mut shunt.control {
460 c.control_bus = c.control_bus.and_then(|b| remap(map, b));
461 for block in &mut c.blocks {
462 block.g /= base;
463 block.b /= base;
464 }
465 }
466 Some((shunt, Some(row)))
467 })
468 .unzip()
469}
470
471fn norm_static_var_compensators(
472 compensators: &[StaticVarCompensator],
473 base: f64,
474 map: &HashMap<BusId, BusId>,
475) -> (Vec<StaticVarCompensator>, Vec<Option<usize>>) {
476 compensators
477 .iter()
478 .enumerate()
479 .filter(|(_, svc)| svc.in_service)
480 .filter_map(|(row, svc)| {
481 let mut normalized = svc.clone();
482 normalized.bus = remap(map, svc.bus)?;
483 normalized.p = svc.p / base;
484 normalized.q = svc.q / base;
485 Some((normalized, Some(row)))
486 })
487 .unzip()
488}
489
490fn norm_branches(
491 branches: &[Branch],
492 base: f64,
493 map: &HashMap<BusId, BusId>,
494) -> (Vec<Branch>, Vec<Option<usize>>) {
495 branches
496 .iter()
497 .enumerate()
498 .filter(|(_, br)| br.in_service)
499 .filter_map(|(row, br)| {
500 let mut branch = br.clone();
501 branch.from = remap(map, br.from)?;
502 branch.to = remap(map, br.to)?;
503 branch.rate_a = br.rate_a / base;
504 branch.rate_b = br.rate_b / base;
505 branch.rate_c = br.rate_c / base;
506 for set in &mut branch.rating_sets {
507 set.rate_mva /= base;
508 }
509 branch.tap = br.calc_effective_tap();
510 branch.shift = br.shift * DEG_TO_RAD;
511 branch.angmin = br.angmin * DEG_TO_RAD;
512 branch.angmax = br.angmax * DEG_TO_RAD;
513 if let Some(s) = &mut branch.solution {
514 s.pf /= base;
515 s.qf /= base;
516 s.pt /= base;
517 s.qt /= base;
518 }
519 if let Some(c) = &mut branch.control {
523 norm_transformer_control(c, base, map);
524 }
525 Some((branch, Some(row)))
526 })
527 .unzip()
528}
529
530fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
531 if options.clamp_angle_bounds
532 && (!options.angle_bound_pad.is_finite()
533 || options.angle_bound_pad <= 0.0
534 || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
535 {
536 return Err(Error::InvalidNormalizeOption {
537 field: "angle_bound_pad",
538 value: options.angle_bound_pad,
539 });
540 }
541 Ok(())
542}
543
544#[allow(clippy::float_cmp)] fn clamp_angle_bounds(
546 branches: &mut [Branch],
547 pad: f64,
548 warnings: &mut crate::diagnostics::Diagnostics,
549) {
550 for (idx, br) in branches.iter_mut().enumerate() {
551 let old_min = br.angmin;
552 let old_max = br.angmax;
553 let mut changes = Vec::new();
554
555 let (corrected_min, corrected_max) =
556 correct_angle_difference_bounds_with_pad(old_min, old_max, pad, 0.0);
557
558 if old_min != corrected_min {
559 br.angmin = corrected_min;
560 changes.push(format!("angmin {old_min} -> {}", br.angmin));
561 }
562 if old_max != corrected_max {
563 br.angmax = corrected_max;
564 changes.push(format!("angmax {old_max} -> {}", br.angmax));
565 }
566
567 if !changes.is_empty() {
568 warnings.push(
569 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_BOUNDS_CLAMPED,
570 format!(
571 "branch {idx} angle difference bounds clamped: {}",
572 changes.join(", ")
573 ),
574 );
575 }
576 }
577}
578
579#[must_use]
591pub fn correct_angle_difference_bounds(angle_min: f64, angle_max: f64) -> (f64, f64) {
592 correct_angle_difference_bounds_with_pad(
593 angle_min,
594 angle_max,
595 POWER_MODELS_ANGLE_BOUND_PAD,
596 0.0,
597 )
598}
599
600#[must_use]
610pub fn stated_angle_difference_bounds(angle_min: f64, angle_max: f64) -> (f64, f64) {
611 let full = std::f64::consts::TAU;
612 if angle_min == 0.0 && angle_max == 0.0 {
613 return (f64::NEG_INFINITY, f64::INFINITY);
614 }
615 let angle_min = if angle_min <= -full {
616 f64::NEG_INFINITY
617 } else {
618 angle_min
619 };
620 let angle_max = if angle_max >= full {
621 f64::INFINITY
622 } else {
623 angle_max
624 };
625 (angle_min, angle_max)
626}
627
628#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
638#[serde(rename_all = "snake_case")]
639#[non_exhaustive]
640pub enum AngleDifferenceBounds {
641 PowerModelsPad,
650 ShiftCenteredPad,
654 Stated,
657 None,
659}
660
661impl AngleDifferenceBounds {
662 #[must_use]
666 pub const fn from_correct_flag(correct: bool) -> Self {
667 if correct {
668 Self::PowerModelsPad
669 } else {
670 Self::Stated
671 }
672 }
673
674 #[must_use]
677 pub const fn is_padded(self) -> bool {
678 matches!(self, Self::PowerModelsPad | Self::ShiftCenteredPad)
679 }
680
681 #[must_use]
684 pub fn calc_bounds(self, angle_min: f64, angle_max: f64, shift: f64) -> (f64, f64) {
685 match self {
686 Self::PowerModelsPad => correct_angle_difference_bounds(angle_min, angle_max),
687 Self::ShiftCenteredPad => correct_angle_difference_bounds_with_pad(
688 angle_min,
689 angle_max,
690 POWER_MODELS_ANGLE_BOUND_PAD,
691 shift,
692 ),
693 Self::Stated => stated_angle_difference_bounds(angle_min, angle_max),
694 Self::None => (f64::NEG_INFINITY, f64::INFINITY),
695 }
696 }
697}
698
699#[allow(clippy::float_cmp)] fn correct_angle_difference_bounds_with_pad(
703 angle_min: f64,
704 angle_max: f64,
705 pad: f64,
706 center: f64,
707) -> (f64, f64) {
708 let (mut low, mut high) = (angle_min, angle_max);
709 if low <= -std::f64::consts::FRAC_PI_2 {
710 low = center - pad;
711 }
712 if high >= std::f64::consts::FRAC_PI_2 {
713 high = center + pad;
714 }
715 if angle_min == 0.0 && angle_max == 0.0 || low > high {
716 return (center - pad, center + pad);
717 }
718 (low, high)
719}
720
721fn norm_gens(
722 gens: &[Generator],
723 base: f64,
724 map: &HashMap<BusId, BusId>,
725) -> (Vec<Generator>, Vec<Option<usize>>) {
726 gens.iter()
727 .enumerate()
728 .filter(|(_, g)| g.in_service)
729 .filter_map(|(row, g)| {
730 let mut generator = g.clone();
731 generator.bus = remap(map, g.bus)?;
732 generator.pg = g.pg / base;
733 generator.qg = g.qg / base;
734 generator.pmax = g.pmax / base;
735 generator.pmin = g.pmin / base;
736 generator.qmax = g.qmax / base;
737 generator.qmin = g.qmin / base;
738 if let Some(c) = &mut generator.cost {
739 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
740 }
741 for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
743 if GEN_PU_KEYS.contains(&key)
744 && let Some(v) = cap
745 {
746 *v /= base;
747 }
748 }
749 generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
752 Some((generator, Some(row)))
753 })
754 .unzip()
755}
756
757fn norm_switches(
758 switches: &[Switch],
759 base: f64,
760 map: &HashMap<BusId, BusId>,
761) -> (Vec<Switch>, Vec<Option<usize>>) {
762 switches
763 .iter()
764 .enumerate()
765 .filter_map(|(row, s)| {
766 let switch = Switch {
767 from: remap(map, s.from)?,
768 to: remap(map, s.to)?,
769 thermal_rating: s.thermal_rating.map(|v| v / base),
770 pf: s.pf.map(|v| v / base),
771 qf: s.qf.map(|v| v / base),
772 pt: s.pt.map(|v| v / base),
773 qt: s.qt.map(|v| v / base),
774 ..s.clone()
775 };
776 Some((switch, Some(row)))
777 })
778 .unzip()
779}
780
781fn norm_storage(
782 storage: &[Storage],
783 base: f64,
784 map: &HashMap<BusId, BusId>,
785) -> (Vec<Storage>, Vec<Option<usize>>) {
786 storage
787 .iter()
788 .enumerate()
789 .filter(|(_, s)| s.in_service)
790 .filter_map(|(row, s)| {
791 let unit = Storage {
794 bus: remap(map, s.bus)?,
795 energy: s.energy / base,
796 energy_rating: s.energy_rating / base,
797 charge_rating: s.charge_rating / base,
798 discharge_rating: s.discharge_rating / base,
799 thermal_rating: s.thermal_rating / base,
800 qmin: s.qmin / base,
801 qmax: s.qmax / base,
802 p_loss: s.p_loss / base,
803 q_loss: s.q_loss / base,
804 ..s.clone()
805 };
806 Some((unit, Some(row)))
807 })
808 .unzip()
809}
810
811fn norm_hvdc(
812 hvdc: &[Hvdc],
813 base: f64,
814 map: &HashMap<BusId, BusId>,
815) -> (Vec<Hvdc>, Vec<Option<usize>>) {
816 hvdc.iter()
817 .enumerate()
818 .filter(|(_, d)| d.in_service)
819 .filter_map(|(row, d)| {
820 let mut link = d.clone();
824 link.from = remap(map, d.from)?;
825 link.to = remap(map, d.to)?;
826 link.pf = d.pf / base;
827 link.pt = d.pt / base;
828 link.qf = d.qf / base;
829 link.qt = d.qt / base;
830 link.qminf = d.qminf / base;
831 link.qmaxf = d.qmaxf / base;
832 link.qmint = d.qmint / base;
833 link.qmaxt = d.qmaxt / base;
834 link.loss0 = d.loss0 / base;
835 if let Some(c) = &mut link.cost {
836 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
837 }
838 Some((link, Some(row)))
839 })
840 .unzip()
841}
842
843fn norm_transformers_3w(
844 xfmrs: &[Transformer3W],
845 base: f64,
846 map: &HashMap<BusId, BusId>,
847 warnings: &mut crate::diagnostics::Diagnostics,
848) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
849 let mut taken_out = Vec::new();
850 let normalized = xfmrs
851 .iter()
852 .enumerate()
853 .filter(|(_, t)| t.in_service)
854 .filter_map(|(row, t)| {
855 let mut live = t.winding_in_service();
865 for (state, winding) in live.iter_mut().zip(&t.windings) {
866 *state &= remap(map, winding.bus).is_some();
867 }
868 let kept_bus = t
869 .windings
870 .iter()
871 .zip(live)
872 .find(|(_, on)| *on)
873 .map(|(winding, _)| winding.bus)?;
874 if live.iter().filter(|&&on| on).count() < 2 {
875 return None;
876 }
877 let mut windings = t.windings.clone();
878 for w in &mut windings {
879 w.bus = remap(map, w.bus).unwrap_or(kept_bus);
880 if let Some(control) = &mut w.control {
881 norm_transformer_control(control, base, map);
882 }
883 w.shift *= DEG_TO_RAD;
884 w.rate_a /= base;
885 w.rate_b /= base;
886 w.rate_c /= base;
887 }
888 let mut transformer = Transformer3W {
889 windings,
890 star_va: t.star_va * DEG_TO_RAD,
891 ..t.clone()
892 };
893 for winding in 0..3 {
894 let mut sets = t.winding_rating_sets(winding);
895 for set in &mut sets {
896 set.rate_mva /= base;
897 }
898 transformer.set_winding_rating_sets(winding, sets);
899 }
900 if live != t.winding_in_service() {
901 transformer.set_winding_in_service(live);
902 taken_out.push(row);
903 }
904 Some((transformer, Some(row)))
905 })
906 .unzip();
907 if !taken_out.is_empty() {
908 let names = taken_out
909 .iter()
910 .take(5)
911 .map(|&row| {
912 xfmrs[row]
913 .uid
914 .clone()
915 .unwrap_or_else(|| format!("transformers_3w:{row}"))
916 })
917 .collect::<Vec<_>>()
918 .join(", ");
919 warnings.push(
920 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_WINDING_TAKEN_OUT,
921 format!(
922 "{} three winding transformer(s) have a winding on a bus normalization drops; \
923 that winding was taken out of service and the other two kept ({names})",
924 taken_out.len()
925 ),
926 );
927 }
928 normalized
929}
930
931fn designate_reference(
935 buses: &mut [Bus],
936 generators: &[Generator],
937 warnings: &mut crate::diagnostics::Diagnostics,
938) -> Result<()> {
939 let slack = generators
940 .iter()
941 .max_by(|a, b| {
942 let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
946 key(a.pmax).total_cmp(&key(b.pmax))
947 })
948 .map(|g| g.bus)
949 .ok_or(Error::NoReferenceBus)?;
950 if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
951 b.kind = BusType::Ref;
952 warnings.push(
953 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_REFERENCE_DESIGNATED,
954 format!(
955 "the case states no reference bus that survives normalization; bus {slack} \
956 hosts the largest pmax in-service generator and was designated the slack"
957 ),
958 );
959 }
960 Ok(())
961}
962
963fn unsupplied_island_buses(
966 network: &BalancedNetwork,
967 partition: &IslandPartition,
968 warnings: &mut crate::diagnostics::Diagnostics,
969) -> HashSet<BusId> {
970 let mut unsupplied = HashSet::new();
971 for island in partition
972 .islands
973 .iter()
974 .filter(|island| !island.is_supplied())
975 {
976 warnings.record(
977 network.unsupplied_island_finding(island, "was left out of the normalized network"),
978 );
979 unsupplied.extend(island.buses.iter().copied());
980 }
981 unsupplied
982}
983
984fn canonicalize_bus_types(
989 buses: &mut [Bus],
990 generators: &[Generator],
991 warnings: &mut crate::diagnostics::Diagnostics,
992) {
993 let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
994 let mut retyped_pq = Vec::new();
995 for b in buses.iter_mut() {
996 if b.kind == BusType::Pq && gen_buses.contains(&b.id) {
997 retyped_pq.push(b.id);
998 }
999 b.kind = match (gen_buses.contains(&b.id), b.kind) {
1000 (true, BusType::Ref) => BusType::Ref,
1001 (true, _) => BusType::Pv,
1002 (false, _) => BusType::Pq,
1003 };
1004 }
1005 if !retyped_pq.is_empty() {
1006 warnings.push(
1007 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GENERATOR_BUS_RETYPED,
1008 format!(
1009 "{} PQ bus(es) host an in-service generator and were retyped PV, so the \
1010 generator holds the bus voltage ({})",
1011 retyped_pq.len(),
1012 bus_list(&retyped_pq)
1013 ),
1014 );
1015 }
1016}
1017
1018fn assign_normalized_references(
1022 buses: &mut [Bus],
1023 generators: &[Generator],
1024 partition: Option<&IslandPartition>,
1025 warnings: &mut crate::diagnostics::Diagnostics,
1026) -> Result<()> {
1027 if let Some(partition) = partition {
1028 assign_normalized_island_references(buses, generators, partition, warnings);
1029 }
1030 if !buses.iter().any(|b| b.kind == BusType::Ref) {
1031 designate_reference(buses, generators, warnings)?;
1032 }
1033 Ok(())
1034}
1035
1036fn assign_normalized_island_references(
1042 buses: &mut [Bus],
1043 generators: &[Generator],
1044 partition: &IslandPartition,
1045 warnings: &mut crate::diagnostics::Diagnostics,
1046) {
1047 let mut island_generators: HashMap<usize, Vec<usize>> = HashMap::new();
1048 for (row, generator) in generators.iter().enumerate() {
1049 if let Some(island) = partition.island_of(generator.bus) {
1050 island_generators.entry(island).or_default().push(row);
1051 }
1052 }
1053 let mut island_references: HashMap<usize, Vec<BusId>> = HashMap::new();
1054 for bus in buses.iter().filter(|bus| bus.kind == BusType::Ref) {
1055 if let Some(island) = partition.island_of(bus.id) {
1056 island_references.entry(island).or_default().push(bus.id);
1057 }
1058 }
1059 let mut designated = HashSet::new();
1060 let mut demoted = HashSet::new();
1061 for (position, island) in partition.islands.iter().enumerate() {
1062 let Some(rows) = island_generators.get(&position) else {
1063 continue;
1064 };
1065 let mut references = island_references.remove(&position).unwrap_or_default();
1066 references.sort_unstable();
1067 match references.as_slice() {
1068 [_] => {}
1069 [] => {
1070 if let Some(bus) = largest_generator_bus(generators, rows) {
1071 warnings.push(
1072 &crate::diagnostics::codes::CANONICALIZE_ISLAND_REFERENCE_DESIGNATED,
1073 format!(
1074 "the island of {} bus(es) ({}) has no reference bus that survives \
1075 normalization; bus {bus} hosts its largest pmax in-service \
1076 generator and was designated the reference",
1077 island.buses.len(),
1078 bus_list(&island.buses)
1079 ),
1080 );
1081 designated.insert(bus);
1082 }
1083 }
1084 many => {
1085 let kept = kept_reference(many, generators, rows).unwrap_or(many[0]);
1086 let others: Vec<BusId> = many.iter().copied().filter(|&b| b != kept).collect();
1087 warnings.push(
1088 &crate::diagnostics::codes::CANONICALIZE_ISLAND_REFERENCE_DEMOTED,
1089 format!(
1090 "the island of {} bus(es) keeps {} reference buses; bus {kept} hosts the \
1091 most generation and stays the reference, and bus(es) {} became PV",
1092 island.buses.len(),
1093 many.len(),
1094 bus_list(&others)
1095 ),
1096 );
1097 demoted.extend(others);
1098 }
1099 }
1100 }
1101 for bus in buses.iter_mut() {
1102 if designated.contains(&bus.id) {
1103 bus.kind = BusType::Ref;
1104 } else if demoted.contains(&bus.id) {
1105 bus.kind = BusType::Pv;
1106 }
1107 }
1108}
1109
1110impl BalancedNetwork {
1111 pub fn to_normalized(&self) -> Result<BalancedNetwork> {
1170 Ok(self
1171 .to_normalized_with_options(&NormalizeOptions::default())?
1172 .network)
1173 }
1174
1175 pub fn to_normalized_with_options(
1178 &self,
1179 options: &NormalizeOptions,
1180 ) -> Result<NormalizedNetwork> {
1181 Ok(self.normalize_inner(&NormalizeSettings::new(*options))?.0)
1182 }
1183
1184 pub fn to_normalized_with(
1194 &self,
1195 settings: &NormalizeSettings,
1196 ) -> Result<(NormalizedNetwork, NormalizeReport)> {
1197 let (normalized, _, report) = self.normalize_inner(settings)?;
1198 Ok((normalized, report))
1199 }
1200
1201 #[doc(hidden)]
1226 pub fn to_normalized_with_source_rows(
1227 &self,
1228 options: &NormalizeOptions,
1229 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
1230 let (normalized, mut rows, _) = self.normalize_inner(&NormalizeSettings::new(*options))?;
1231 rows.pad_to_lowered(&normalized.network);
1232 Ok((normalized, rows))
1233 }
1234
1235 fn normalize_inner(
1240 &self,
1241 settings: &NormalizeSettings,
1242 ) -> Result<(NormalizedNetwork, NormalizeSourceRows, NormalizeReport)> {
1243 let options = &settings.options;
1244 validate_normalize_options(options)?;
1245 let (kept, left) = self.closed_switch_rows();
1246 if kept.is_empty() || settings.closed_switches == ClosedSwitchPolicy::Keep {
1247 let (normalized, rows) = self.normalize_pass(settings)?;
1248 return Ok((normalized, rows, NormalizeReport::default()));
1249 }
1250 if settings.closed_switches == ClosedSwitchPolicy::Refuse {
1251 let switch = &self.switches()[kept[0]];
1252 return Err(Error::ClosedSwitch {
1253 row: kept[0],
1254 from: switch.from,
1255 to: switch.to,
1256 closed: kept.len(),
1257 });
1258 }
1259 let merge = self.merge_normalization_switches(&left)?;
1260 let mut remaining = kept
1264 .into_iter()
1265 .filter(|&row| merge.switch_rows[row].is_some());
1266 if let Some(row) = remaining.next() {
1267 let switch = &self.switches()[row];
1268 return Err(Error::ClosedSwitch {
1269 row,
1270 from: switch.from,
1271 to: switch.to,
1272 closed: 1 + remaining.count(),
1273 });
1274 }
1275 let (mut normalized, rows) = merge.network.normalize_pass(settings)?;
1276 let rows = rows.through_merge(&merge);
1277 let mut diagnostics = merge.diagnostics.clone();
1278 diagnostics.append(&mut normalized.diagnostics);
1279 normalized.warnings = crate::diagnostics::render_diagnostics(&diagnostics);
1280 normalized.diagnostics = diagnostics;
1281 Ok((
1282 normalized,
1283 rows,
1284 NormalizeReport {
1285 bus_merge: Some(merge),
1286 },
1287 ))
1288 }
1289
1290 fn merge_normalization_switches(&self, left: &[usize]) -> Result<BusMerge> {
1293 if left.is_empty() {
1294 return self.merge_buses(&BusMergeRule::closed_switches());
1295 }
1296 let mut eligible = self.clone();
1297 let excluded: HashSet<_> = left.iter().copied().collect();
1298 let mut source_rows = Vec::new();
1299 let mut row = 0;
1300 eligible.switches_mut().retain_mut(|switch| {
1301 let keep = !excluded.contains(&row);
1302 if keep {
1303 source_rows.push(row);
1304 if switch.uid.is_none() {
1307 switch.uid = Some(format!("switches:{row}"));
1308 }
1309 }
1310 row += 1;
1311 keep
1312 });
1313 let mut merge = eligible.merge_buses(&BusMergeRule::closed_switches())?;
1316 let mut switch_rows = vec![None; self.switches().len()];
1317 for (&source, &merged) in source_rows.iter().zip(&merge.switch_rows) {
1318 switch_rows[source] = merged;
1319 if let Some(row) = merged {
1320 merge.network.switches_mut()[row]
1321 .uid
1322 .clone_from(&self.switches()[source].uid);
1323 }
1324 }
1325 for removed in &mut merge.removed_switches {
1326 removed.row = source_rows[removed.row];
1327 }
1328 merge.switch_rows = switch_rows;
1329 merge.source = self.clone();
1330 Ok(merge)
1331 }
1332
1333 fn closed_switch_rows(&self) -> (Vec<usize>, Vec<usize>) {
1336 let kept_bus: HashMap<BusId, bool> = self
1337 .buses()
1338 .iter()
1339 .map(|bus| (bus.id, bus.kind != BusType::Isolated))
1340 .collect();
1341 let kept = |bus: BusId| kept_bus.get(&bus).copied().unwrap_or(false);
1342 self.switches().iter().enumerate().fold(
1343 (Vec::new(), Vec::new()),
1344 |(mut eligible, mut left), (row, switch)| {
1345 if !kept(switch.from) || !kept(switch.to) {
1346 left.push(row);
1347 } else if switch.closed && switch.from != switch.to {
1348 eligible.push(row);
1349 }
1350 (eligible, left)
1351 },
1352 )
1353 }
1354
1355 fn normalize_pass(
1357 &self,
1358 settings: &NormalizeSettings,
1359 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
1360 let options = &settings.options;
1361 validate_normalize_options(options)?;
1362 self.check_base_mva()?;
1363 let base = self.base_mva();
1364 let mut warnings = crate::diagnostics::Diagnostics::new();
1365
1366 let partition = (settings.island_references == IslandReferencePolicy::PerIsland)
1372 .then(|| self.calc_islands());
1373 let unsupplied = partition
1374 .as_ref()
1375 .map(|partition| unsupplied_island_buses(self, partition, &mut warnings))
1376 .unwrap_or_default();
1377
1378 let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses().len());
1381 let mut buses: Vec<Bus> = Vec::with_capacity(self.buses().len());
1382 let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses().len());
1386 for (row, b) in self.buses().iter().enumerate() {
1387 if b.kind == BusType::Isolated || unsupplied.contains(&b.id) {
1388 continue;
1389 }
1390 id_map.insert(b.id, b.id);
1391 buses.push(Bus {
1392 va: b.va * DEG_TO_RAD,
1393 ..b.clone()
1394 });
1395 bus_rows.push(Some(row));
1396 }
1397 let (loads, load_rows) = norm_loads(self.loads(), base, &id_map);
1398 let (shunts, shunt_rows) = norm_shunts(self.shunts(), base, &id_map);
1399 let (static_var_compensators, static_var_compensator_rows) =
1400 norm_static_var_compensators(self.static_var_compensators(), base, &id_map);
1401 let (mut branches, branch_rows) = norm_branches(self.branches(), base, &id_map);
1402 if options.clamp_angle_bounds {
1403 clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
1404 }
1405 let (switches, switch_rows) = norm_switches(self.switches(), base, &id_map);
1406 let (generators, generator_rows) = norm_gens(self.generators(), base, &id_map);
1407 let (storage, storage_rows) = norm_storage(self.storage(), base, &id_map);
1408 let (hvdc, hvdc_rows) = norm_hvdc(self.hvdc(), base, &id_map);
1409 let (transformers_3w, transformer_3w_rows) =
1410 norm_transformers_3w(self.transformers_3w(), base, &id_map, &mut warnings);
1411 let source_rows = NormalizeSourceRows {
1412 buses: bus_rows,
1413 loads: load_rows,
1414 shunts: shunt_rows,
1415 static_var_compensators: static_var_compensator_rows,
1416 branches: branch_rows,
1417 switches: switch_rows,
1418 generators: generator_rows,
1419 storage: storage_rows,
1420 hvdc: hvdc_rows,
1421 transformers_3w: transformer_3w_rows,
1422 };
1423
1424 canonicalize_bus_types(&mut buses, &generators, &mut warnings);
1428 assign_normalized_references(&mut buses, &generators, partition.as_ref(), &mut warnings)?;
1429 if !generators.is_empty() && generators.iter().all(|g| g.cost.is_none()) {
1432 warnings.push(
1433 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GEN_COST_ABSENT,
1434 format!(
1435 "the case has {} in-service generator(s) and no cost data; any cost \
1436 objective built from it is identically zero",
1437 generators.len()
1438 ),
1439 );
1440 }
1441
1442 let mut net = BalancedNetwork::from_tables(BalancedNetworkTables {
1443 name: self.name().clone(),
1444 base_mva: base,
1445 base_frequency: self.base_frequency(),
1446 geo: self.geo().clone(),
1447 case_metadata: self.case_metadata().clone(),
1448 detailed_connectivity: self.detailed_connectivity().clone(),
1449 generated_uids: self.generated_uids().clone(),
1450 buses: buses.into(),
1451 loads: loads.into(),
1452 shunts: shunts.into(),
1453 static_var_compensators: static_var_compensators.into(),
1454 branches: branches.into(),
1455 switches: switches.into(),
1456 generators: generators.into(),
1457 storage: storage.into(),
1458 hvdc: hvdc.into(),
1459 transformers_3w: transformers_3w.into(),
1460 areas: Vec::new().into(),
1463 solver: None,
1464 source_format: SourceFormat::Normalized,
1465 });
1466 crate::operations::filter_connectivity(self, &mut net);
1469 debug_assert!(
1473 net.validate().is_ok(),
1474 "to_normalized produced a dangling reference"
1475 );
1476 Ok((
1477 NormalizedNetwork {
1478 network: net,
1479 warnings: warnings.lines(),
1480 diagnostics: warnings.into_records(),
1481 },
1482 source_rows,
1483 ))
1484 }
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489 use super::*;
1490
1491 #[test]
1492 fn normalization_merges_closed_switches_unless_told_otherwise() {
1493 use crate::network::Switch;
1494 let mut buses: Vec<Bus> = (1..=4)
1495 .map(|id| Bus::new(BusId(id), BusType::Pq, 230.0))
1496 .collect();
1497 buses[0].kind = BusType::Ref;
1498 buses[3].kind = BusType::Isolated;
1499 let mut net = BalancedNetwork::in_memory(
1500 "switched",
1501 100.0,
1502 buses,
1503 vec![Branch::new(BusId(1), BusId(2), 0.0, 0.1)],
1504 );
1505 net.switches_mut().extend([
1508 Switch::new(BusId(2), BusId(3), true),
1509 Switch::new(BusId(1), BusId(3), false),
1510 Switch::new(BusId(2), BusId(4), true),
1511 ]);
1512 net.generators_mut().push(Generator::new(BusId(1)));
1513 net.loads_mut().push(Load::new(BusId(3), 10.0, 0.0));
1514
1515 let (merged, rows) = net
1517 .to_normalized_with_source_rows(&NormalizeOptions::default())
1518 .unwrap();
1519 assert_eq!(merged.network.buses().len(), 2);
1520 assert_eq!(merged.network.loads()[0].bus, BusId(2));
1521 crate::IndexedNetwork::new(&merged.network)
1522 .check_closed_switches()
1523 .unwrap();
1524 assert!(
1525 merged
1526 .diagnostics
1527 .iter()
1528 .any(|d| d.code() == "CANONICALIZE.MERGE.CLOSED_SWITCH")
1529 );
1530 assert_eq!(rows.switches, [Some(1)]);
1532 assert_eq!(rows.buses, [Some(0), Some(1)]);
1533 assert_eq!(rows.branches, [Some(0)]);
1534 let plain = net.to_normalized().unwrap();
1535 assert_eq!(plain.buses(), merged.network.buses());
1536 assert_eq!(plain.switches(), merged.network.switches());
1537
1538 let (_, report) = net
1539 .to_normalized_with(&NormalizeSettings::default())
1540 .unwrap();
1541 let merge = report.bus_merge.unwrap();
1542 assert_eq!(merge.survivor(BusId(3)), BusId(2));
1543 assert_eq!(merge.survivor(BusId(4)), BusId(4));
1544
1545 let (kept, report) = net
1547 .to_normalized_with(
1548 &NormalizeSettings::default().with_closed_switches(ClosedSwitchPolicy::Keep),
1549 )
1550 .unwrap();
1551 assert!(report.bus_merge.is_none());
1552 assert_eq!(kept.network.buses().len(), 3);
1553 let error = crate::IndexedNetwork::new(&kept.network)
1554 .check_closed_switches()
1555 .unwrap_err();
1556 assert!(error.to_string().starts_with("1 closed switch(es)"));
1557
1558 let error = net
1560 .to_normalized_with(
1561 &NormalizeSettings::default().with_closed_switches(ClosedSwitchPolicy::Refuse),
1562 )
1563 .unwrap_err();
1564 assert!(matches!(
1565 error,
1566 Error::ClosedSwitch {
1567 row: 0,
1568 closed: 1,
1569 ..
1570 }
1571 ));
1572 let message = error.to_string();
1573 for call in [
1574 "BusMergeRule::closed_switches()",
1575 "closed_switches=\"merge\"",
1576 "--merge-buses switches",
1577 ] {
1578 assert!(message.contains(call), "{message}");
1579 }
1580
1581 net.switches_mut()[0].closed = false;
1583 net.switches_mut()[2].closed = false;
1584 let (plain, report) = net
1585 .to_normalized_with(&NormalizeSettings::default())
1586 .unwrap();
1587 assert!(report.bus_merge.is_none());
1588 assert_eq!(plain.network.buses().len(), 3);
1589 }
1590 use crate::network::GeneratorEnergySource;
1591
1592 fn approx(a: f64, b: f64) -> bool {
1593 (a - b).abs() < 1e-9
1594 }
1595
1596 fn angle_bound_fixture() -> BalancedNetwork {
1597 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1598 .join("../tests/data/angle_bounds_clamp.m");
1599 crate::parse_file(path, None).unwrap().network
1600 }
1601
1602 #[test]
1603 fn transformer_control_normalization_follows_psse_field_units_for_every_mode() {
1604 let map = HashMap::from([(BusId(7), BusId(7))]);
1609 let cases = [
1610 (TransformerControlMode::Fixed, false, false),
1611 (TransformerControlMode::Voltage, false, false),
1612 (TransformerControlMode::ReactiveFlow, false, true),
1613 (TransformerControlMode::ActiveFlow, true, true),
1614 (TransformerControlMode::DcLineQuantity, false, false),
1615 (TransformerControlMode::AsymmetricActiveFlow, true, true),
1616 ];
1617
1618 for (mode, angle_limits, power_band) in cases {
1619 let mut control = TransformerControl::new(mode);
1620 control.controlled_bus = Some(BusId(7));
1621 control.tap_min = -10.0;
1622 control.tap_max = 20.0;
1623 control.band_min = -50.0;
1624 control.band_max = 75.0;
1625 control.winding_connection_angle =
1626 (mode == TransformerControlMode::AsymmetricActiveFlow).then_some(30.0);
1627
1628 norm_transformer_control(&mut control, 100.0, &map);
1629
1630 let tap_scale = if angle_limits { DEG_TO_RAD } else { 1.0 };
1631 let band_scale = if power_band { 0.01 } else { 1.0 };
1632 assert!(approx(control.tap_min, -10.0 * tap_scale), "{mode:?}");
1633 assert!(approx(control.tap_max, 20.0 * tap_scale), "{mode:?}");
1634 assert!(approx(control.band_min, -50.0 * band_scale), "{mode:?}");
1635 assert!(approx(control.band_max, 75.0 * band_scale), "{mode:?}");
1636 assert_eq!(control.controlled_bus, Some(BusId(7)), "{mode:?}");
1637 if mode == TransformerControlMode::AsymmetricActiveFlow {
1638 assert!(approx(
1639 control.winding_connection_angle.unwrap(),
1640 30.0 * DEG_TO_RAD
1641 ));
1642 } else {
1643 assert_eq!(control.winding_connection_angle, None, "{mode:?}");
1644 }
1645 }
1646 }
1647
1648 #[test]
1649 fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
1650 let net = angle_bound_fixture();
1651
1652 let plain = net.to_normalized().unwrap();
1653 assert!(approx(plain.branches()[0].angmin, -std::f64::consts::TAU));
1654 assert!(approx(plain.branches()[0].angmax, std::f64::consts::TAU));
1655 assert!(approx(plain.branches()[1].angmin, 0.0));
1656 assert!(approx(plain.branches()[1].angmax, 0.0));
1657 assert!(approx(plain.branches()[3].angmin, -120.0 * DEG_TO_RAD));
1658 assert!(approx(plain.branches()[3].angmax, -100.0 * DEG_TO_RAD));
1659 assert!(approx(plain.branches()[4].angmin, 100.0 * DEG_TO_RAD));
1660 assert!(approx(plain.branches()[4].angmax, 120.0 * DEG_TO_RAD));
1661
1662 let out = net
1663 .to_normalized_with_options(&NormalizeOptions {
1664 clamp_angle_bounds: true,
1665 ..NormalizeOptions::default()
1666 })
1667 .unwrap();
1668 let clamps: Vec<&String> = out
1671 .warnings
1672 .iter()
1673 .filter(|w| w.contains("BOUNDS_CLAMPED"))
1674 .collect();
1675 assert_eq!(clamps.len(), 4, "{:?}", out.warnings);
1676 assert!(clamps[0].contains("branch 0"));
1677 assert!(clamps[1].contains("branch 1"));
1678 assert!(clamps[2].contains("branch 3"));
1679 assert!(clamps[3].contains("branch 4"));
1680
1681 let branches = &out.network.branches();
1682 assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1683 assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1684 assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1685 assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1686 assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
1687 assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
1688 assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1689 assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1690 assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
1691 assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
1692 assert!(branches.iter().all(|br| br.angmin <= br.angmax));
1693 }
1694
1695 #[test]
1696 fn angle_bound_clamp_rejects_invalid_pad() {
1697 let net = angle_bound_fixture();
1698 let err = net
1699 .to_normalized_with_options(&NormalizeOptions {
1700 clamp_angle_bounds: true,
1701 angle_bound_pad: std::f64::consts::FRAC_PI_2,
1702 })
1703 .unwrap_err();
1704 assert!(matches!(
1705 err,
1706 Error::InvalidNormalizeOption {
1707 field: "angle_bound_pad",
1708 ..
1709 }
1710 ));
1711 }
1712
1713 #[test]
1714 fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
1715 use crate::network::{Extras, ShuntBlock, SwitchedShuntControl, SwitchedShuntMode};
1716
1717 let mkbus = |id: usize, kind: BusType| Bus {
1718 id: BusId(id),
1719 kind,
1720 vm: 1.0,
1721 va: 0.0,
1722 base_kv: 230.0,
1723 vmax: 1.1,
1724 vmin: 0.9,
1725 evhi: None,
1726 evlo: None,
1727 area: 1,
1728 zone: 1,
1729 name: None,
1730 uid: None,
1731 location: None,
1732 extras: Extras::new(),
1733 };
1734 let branch = Branch {
1735 name: None,
1736 from: BusId(1),
1737 to: BusId(2),
1738 r: 0.0,
1739 x: 0.1,
1740 b: 0.0,
1741 charging: None,
1742 rate_a: 0.0,
1743 rate_b: 0.0,
1744 rate_c: 0.0,
1745 rating_sets: Vec::new(),
1746 current_ratings: None,
1747 tap: 0.0,
1748 shift: 0.0,
1749 in_service: true,
1750 angmin: -360.0,
1751 angmax: 360.0,
1752 control: None,
1753 solution: None,
1754 uid: None,
1755 route: None,
1756 extras: Extras::new(),
1757 };
1758 let mut net = BalancedNetwork::in_memory(
1760 "n",
1761 100.0,
1762 vec![
1763 mkbus(1, BusType::Ref),
1764 mkbus(2, BusType::Pq),
1765 mkbus(3, BusType::Isolated),
1766 ],
1767 vec![branch],
1768 );
1769 net.generators_mut().push(Generator {
1770 bus: BusId(1),
1771 energy_source: GeneratorEnergySource::default(),
1772 pg: 10.0,
1773 qg: 0.0,
1774 pmax: 100.0,
1775 pmin: 0.0,
1776 qmax: 50.0,
1777 qmin: -50.0,
1778 vg: 1.0,
1779 mbase: 100.0,
1780 in_service: true,
1781 cost: None,
1782 caps: Default::default(),
1783 voltage_regulation_on: true,
1784 regulating_terminal: None,
1785 regulated_bus: None,
1786 active_power_control: None,
1787 uid: None,
1788 });
1789 net.shunts_mut().push(Shunt {
1791 bus: BusId(2),
1792 g: 0.0,
1793 b: 10.0,
1794 in_service: true,
1795 section_count: None,
1796 control: Some(SwitchedShuntControl {
1797 mode: SwitchedShuntMode::Discrete,
1798 vhigh: 1.05,
1799 vlow: 0.95,
1800 control_bus: Some(BusId(3)),
1801 regulating_terminal: None,
1802 rmpct: 100.0,
1803 blocks: vec![ShuntBlock::with_admittance(2, 4.0, 20.0)],
1804 }),
1805 uid: None,
1806 extras: Extras::new(),
1807 });
1808
1809 let norm = net.to_normalized().unwrap();
1810 norm.validate().unwrap();
1811 let c = norm.shunts()[0].control.as_ref().expect("control retained");
1812 assert_eq!(
1813 c.control_bus, None,
1814 "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
1815 );
1816 assert!(approx(c.blocks[0].g, 0.04));
1817 assert!(approx(c.blocks[0].b, 0.2));
1818 }
1819
1820 #[test]
1821 fn normalized_slack_tiebreak_ignores_nan_pmax() {
1822 use crate::network::Extras;
1823
1824 let mkbus = |id: usize| Bus {
1825 id: BusId(id),
1826 kind: BusType::Pq,
1827 vm: 1.0,
1828 va: 0.0,
1829 base_kv: 230.0,
1830 vmax: 1.1,
1831 vmin: 0.9,
1832 evhi: None,
1833 evlo: None,
1834 area: 1,
1835 zone: 1,
1836 name: None,
1837 uid: None,
1838 location: None,
1839 extras: Extras::new(),
1840 };
1841 let mkgen = |bus: usize, pmax: f64| Generator {
1842 bus: BusId(bus),
1843 energy_source: GeneratorEnergySource::default(),
1844 pg: 0.0,
1845 qg: 0.0,
1846 pmax,
1847 pmin: 0.0,
1848 qmax: 0.0,
1849 qmin: 0.0,
1850 vg: 1.0,
1851 mbase: 100.0,
1852 in_service: true,
1853 cost: None,
1854 caps: Default::default(),
1855 voltage_regulation_on: true,
1856 regulating_terminal: None,
1857 regulated_bus: None,
1858 active_power_control: None,
1859 uid: None,
1860 };
1861 let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
1862 *net.generators_mut() = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
1863 let norm = net.to_normalized().unwrap();
1864
1865 assert_eq!(
1866 norm.buses().iter().find(|b| b.id == BusId(1)).unwrap().kind,
1867 BusType::Pv
1868 );
1869 assert_eq!(
1870 norm.buses().iter().find(|b| b.id == BusId(2)).unwrap().kind,
1871 BusType::Ref
1872 );
1873 }
1874
1875 #[test]
1876 fn cost_to_pu_polynomial_scales_and_trims() {
1877 let cost = GenCost {
1880 model: 2,
1881 startup: 0.0,
1882 shutdown: 0.0,
1883 ncost: 2,
1884 coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1885 };
1886 let out = cost_to_pu(&cost, 100.0);
1887 assert_eq!(out.len(), 2, "padding dropped");
1888 assert!(approx(out[0], 2403.5)); assert!(approx(out[1], -403.5)); }
1891
1892 #[test]
1893 fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1894 let cost = GenCost {
1896 model: 1,
1897 startup: 0.0,
1898 shutdown: 0.0,
1899 ncost: 4,
1900 coeffs: vec![
1901 0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1902 ],
1903 };
1904 let out = cost_to_pu(&cost, 100.0);
1905 assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1906 assert!(
1907 approx(out[0], 0.0)
1908 && approx(out[2], 1.0)
1909 && approx(out[4], 2.0)
1910 && approx(out[6], 2.5)
1911 );
1912 assert!(
1913 approx(out[1], 0.0)
1914 && approx(out[3], 2500.0)
1915 && approx(out[5], 5500.0)
1916 && approx(out[7], 7250.0)
1917 );
1918 }
1919
1920 #[test]
1921 fn cost_rescale_round_trips() {
1922 let cost = GenCost {
1924 model: 2,
1925 startup: 0.0,
1926 shutdown: 0.0,
1927 ncost: 3,
1928 coeffs: vec![0.11, 5.0, 150.0],
1929 };
1930 let pu = cost_to_pu(&cost, 100.0);
1931 assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1933 assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1934 assert!((pu[2] - 150.0).abs() < 1e-9);
1935 let back = cost_from_pu(&pu, 2, 100.0);
1936 for (a, b) in back.iter().zip(&cost.coeffs) {
1937 assert!((a - b).abs() < 1e-9);
1938 }
1939 }
1940
1941 #[test]
1942 fn cost_rescale_passes_through_unknown_model() {
1943 let cost = GenCost {
1947 model: 0,
1948 startup: 0.0,
1949 shutdown: 0.0,
1950 ncost: 2,
1951 coeffs: vec![3.0, 7.0, 9.0],
1952 };
1953 let pu = cost_to_pu(&cost, 100.0);
1954 assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1955 let back = cost_from_pu(&pu, cost.model, 100.0);
1956 assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1957 }
1958
1959 #[test]
1960 fn cost_rescale_round_trips_piecewise() {
1961 let cost = GenCost {
1965 model: 1,
1966 startup: 0.0,
1967 shutdown: 0.0,
1968 ncost: 4,
1969 coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1970 };
1971 let pu = cost_to_pu(&cost, 100.0);
1972 let back = cost_from_pu(&pu, 1, 100.0);
1973 for (a, b) in back.iter().zip(&cost.coeffs) {
1974 assert!((a - b).abs() < 1e-9, "{a} != {b}");
1975 }
1976 }
1977}