1use std::collections::{HashMap, HashSet};
16
17use crate::network::{
18 BalancedNetwork, BalancedNetworkTables, Branch, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost,
19 Generator, Hvdc, Load, LoadVoltageModel, Shunt, SourceFormat, StaticVarCompensator, Storage,
20 Switch, Transformer3W, TransformerControl, TransformerControlMode,
21};
22use crate::{Error, Result};
23
24pub(crate) const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
27
28pub(crate) const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
31
32fn norm_transformer_control(
33 control: &mut TransformerControl,
34 base_mva: f64,
35 map: &HashMap<BusId, BusId>,
36) {
37 control.controlled_bus = control
38 .controlled_bus
39 .and_then(|controlled_bus| remap(map, controlled_bus));
40 if matches!(
44 control.mode,
45 TransformerControlMode::ActiveFlow | TransformerControlMode::AsymmetricActiveFlow
46 ) {
47 control.tap_min *= DEG_TO_RAD;
48 control.tap_max *= DEG_TO_RAD;
49 }
50 if matches!(
53 control.mode,
54 TransformerControlMode::ReactiveFlow
55 | TransformerControlMode::ActiveFlow
56 | TransformerControlMode::AsymmetricActiveFlow
57 ) {
58 control.band_min /= base_mva;
59 control.band_max /= base_mva;
60 }
61 if let Some(angle) = &mut control.winding_connection_angle {
62 *angle *= DEG_TO_RAD;
63 }
64}
65
66pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
71
72#[allow(clippy::approx_constant)]
74pub const POWER_MODELS_ANGLE_BOUND_PAD: f64 = 1.0472;
75
76#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct NormalizeOptions {
79 pub clamp_angle_bounds: bool,
82 pub angle_bound_pad: f64,
84}
85
86impl Default for NormalizeOptions {
87 fn default() -> Self {
88 Self {
89 clamp_angle_bounds: false,
90 angle_bound_pad: POWER_MODELS_ANGLE_BOUND_PAD,
91 }
92 }
93}
94
95#[derive(Clone, Debug)]
97pub struct NormalizedNetwork {
98 pub network: BalancedNetwork,
99 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
101 pub warnings: Vec<String>,
103}
104
105#[derive(Clone, Debug)]
127#[non_exhaustive]
128pub struct NormalizeSourceRows {
129 pub buses: Vec<Option<usize>>,
130 pub loads: Vec<Option<usize>>,
131 pub shunts: Vec<Option<usize>>,
132 pub static_var_compensators: Vec<Option<usize>>,
133 pub branches: Vec<Option<usize>>,
134 pub switches: Vec<Option<usize>>,
135 pub generators: Vec<Option<usize>>,
136 pub storage: Vec<Option<usize>>,
137 pub hvdc: Vec<Option<usize>>,
138 pub transformers_3w: Vec<Option<usize>>,
139}
140
141impl NormalizeSourceRows {
142 pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
147 let lengths = net.lowered_lengths();
148 self.buses.resize(lengths.buses, None);
149 self.branches.resize(lengths.branches, None);
150 self.shunts.resize(lengths.shunts, None);
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155enum CostModel {
156 Piecewise,
157 Polynomial,
158 Unknown,
159}
160
161impl From<u8> for CostModel {
162 fn from(value: u8) -> Self {
163 match value {
164 1 => CostModel::Piecewise,
165 2 => CostModel::Polynomial,
166 _ => CostModel::Unknown,
167 }
168 }
169}
170
171pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
183 let mut coeffs = cost.coeffs.clone();
184 scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
185 coeffs
186}
187
188pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
191 match CostModel::from(model) {
192 CostModel::Polynomial => {
193 coeffs.truncate(ncost.min(coeffs.len()));
194 let k = coeffs.len();
195 for (i, c) in coeffs.iter_mut().enumerate() {
198 *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
199 }
200 }
201 CostModel::Piecewise => {
202 coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
206 for c in coeffs.iter_mut().step_by(2) {
207 *c /= base;
208 }
209 }
210 CostModel::Unknown => {}
211 }
212}
213
214pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
220 let k = coeffs.len();
221 match CostModel::from(model) {
222 CostModel::Polynomial => coeffs
223 .iter()
224 .enumerate()
225 .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
226 .collect(),
227 CostModel::Piecewise => coeffs
228 .iter()
229 .enumerate()
230 .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
231 .collect(),
232 CostModel::Unknown => coeffs.to_vec(),
233 }
234}
235
236fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
238 map.get(&id).copied()
239}
240
241fn norm_loads(
242 loads: &[Load],
243 base: f64,
244 map: &HashMap<BusId, BusId>,
245) -> (Vec<Load>, Vec<Option<usize>>) {
246 loads
247 .iter()
248 .enumerate()
249 .filter(|(_, l)| l.in_service)
250 .filter_map(|(row, l)| {
251 Some((
252 Load {
253 bus: remap(map, l.bus)?,
254 p: l.p / base,
255 q: l.q / base,
256 voltage_model: l
257 .voltage_model
258 .as_ref()
259 .map(|m| norm_load_voltage_model(m, base)),
260 ..l.clone()
261 },
262 Some(row),
263 ))
264 })
265 .unzip()
266}
267
268fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
269 match model {
270 LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
271 LoadVoltageModel::Zip {
272 p_constant_power,
273 q_constant_power,
274 p_constant_current,
275 q_constant_current,
276 p_constant_impedance,
277 q_constant_impedance,
278 v_nom,
279 load_type,
280 scaling,
281 } => LoadVoltageModel::Zip {
282 p_constant_power: p_constant_power / base,
283 q_constant_power: q_constant_power / base,
284 p_constant_current: p_constant_current / base,
285 q_constant_current: q_constant_current / base,
286 p_constant_impedance: p_constant_impedance / base,
287 q_constant_impedance: q_constant_impedance / base,
288 v_nom: *v_nom,
289 load_type: *load_type,
290 scaling: *scaling,
291 },
292 LoadVoltageModel::Exponential {
293 p,
294 q,
295 v_nom,
296 gamma_p,
297 gamma_q,
298 } => LoadVoltageModel::Exponential {
299 p: p / base,
300 q: q / base,
301 v_nom: *v_nom,
302 gamma_p: *gamma_p,
303 gamma_q: *gamma_q,
304 },
305 }
306}
307
308fn norm_shunts(
309 shunts: &[Shunt],
310 base: f64,
311 map: &HashMap<BusId, BusId>,
312) -> (Vec<Shunt>, Vec<Option<usize>>) {
313 shunts
314 .iter()
315 .enumerate()
316 .filter(|(_, s)| s.in_service)
317 .filter_map(|(row, s)| {
318 let mut shunt = s.clone();
319 shunt.bus = remap(map, s.bus)?;
320 shunt.g = s.g / base;
321 shunt.b = s.b / base;
322 if let Some(c) = &mut shunt.control {
325 c.control_bus = c.control_bus.and_then(|b| remap(map, b));
326 for block in &mut c.blocks {
327 block.g /= base;
328 block.b /= base;
329 }
330 }
331 Some((shunt, Some(row)))
332 })
333 .unzip()
334}
335
336fn norm_static_var_compensators(
337 compensators: &[StaticVarCompensator],
338 base: f64,
339 map: &HashMap<BusId, BusId>,
340) -> (Vec<StaticVarCompensator>, Vec<Option<usize>>) {
341 compensators
342 .iter()
343 .enumerate()
344 .filter(|(_, svc)| svc.in_service)
345 .filter_map(|(row, svc)| {
346 let mut normalized = svc.clone();
347 normalized.bus = remap(map, svc.bus)?;
348 normalized.p = svc.p / base;
349 normalized.q = svc.q / base;
350 Some((normalized, Some(row)))
351 })
352 .unzip()
353}
354
355fn norm_branches(
356 branches: &[Branch],
357 base: f64,
358 map: &HashMap<BusId, BusId>,
359) -> (Vec<Branch>, Vec<Option<usize>>) {
360 branches
361 .iter()
362 .enumerate()
363 .filter(|(_, br)| br.in_service)
364 .filter_map(|(row, br)| {
365 let mut branch = br.clone();
366 branch.from = remap(map, br.from)?;
367 branch.to = remap(map, br.to)?;
368 branch.rate_a = br.rate_a / base;
369 branch.rate_b = br.rate_b / base;
370 branch.rate_c = br.rate_c / base;
371 for set in &mut branch.rating_sets {
372 set.rate_mva /= base;
373 }
374 branch.tap = br.calc_effective_tap();
375 branch.shift = br.shift * DEG_TO_RAD;
376 branch.angmin = br.angmin * DEG_TO_RAD;
377 branch.angmax = br.angmax * DEG_TO_RAD;
378 if let Some(s) = &mut branch.solution {
379 s.pf /= base;
380 s.qf /= base;
381 s.pt /= base;
382 s.qt /= base;
383 }
384 if let Some(c) = &mut branch.control {
388 norm_transformer_control(c, base, map);
389 }
390 Some((branch, Some(row)))
391 })
392 .unzip()
393}
394
395fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
396 if options.clamp_angle_bounds
397 && (!options.angle_bound_pad.is_finite()
398 || options.angle_bound_pad <= 0.0
399 || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
400 {
401 return Err(Error::InvalidNormalizeOption {
402 field: "angle_bound_pad",
403 value: options.angle_bound_pad,
404 });
405 }
406 Ok(())
407}
408
409#[allow(clippy::float_cmp)] fn clamp_angle_bounds(
411 branches: &mut [Branch],
412 pad: f64,
413 warnings: &mut crate::diagnostics::Diagnostics,
414) {
415 for (idx, br) in branches.iter_mut().enumerate() {
416 let old_min = br.angmin;
417 let old_max = br.angmax;
418 let mut changes = Vec::new();
419
420 let (corrected_min, corrected_max) =
421 correct_angle_difference_bounds_with_pad(old_min, old_max, pad);
422
423 if old_min != corrected_min {
424 br.angmin = corrected_min;
425 changes.push(format!("angmin {old_min} -> {}", br.angmin));
426 }
427 if old_max != corrected_max {
428 br.angmax = corrected_max;
429 changes.push(format!("angmax {old_max} -> {}", br.angmax));
430 }
431
432 if !changes.is_empty() {
433 warnings.push(
434 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_BOUNDS_CLAMPED,
435 format!(
436 "branch {idx} angle difference bounds clamped: {}",
437 changes.join(", ")
438 ),
439 );
440 }
441 }
442}
443
444#[must_use]
452pub fn correct_angle_difference_bounds(angle_min: f64, angle_max: f64) -> (f64, f64) {
453 correct_angle_difference_bounds_with_pad(angle_min, angle_max, POWER_MODELS_ANGLE_BOUND_PAD)
454}
455
456fn correct_angle_difference_bounds_with_pad(
457 mut angle_min: f64,
458 mut angle_max: f64,
459 pad: f64,
460) -> (f64, f64) {
461 if angle_min <= -std::f64::consts::FRAC_PI_2 {
462 angle_min = -pad;
463 }
464 if angle_max >= std::f64::consts::FRAC_PI_2 {
465 angle_max = pad;
466 }
467 if angle_min == 0.0 && angle_max == 0.0 || angle_min > angle_max {
468 return (-pad, pad);
469 }
470 (angle_min, angle_max)
471}
472
473fn norm_gens(
474 gens: &[Generator],
475 base: f64,
476 map: &HashMap<BusId, BusId>,
477) -> (Vec<Generator>, Vec<Option<usize>>) {
478 gens.iter()
479 .enumerate()
480 .filter(|(_, g)| g.in_service)
481 .filter_map(|(row, g)| {
482 let mut generator = g.clone();
483 generator.bus = remap(map, g.bus)?;
484 generator.pg = g.pg / base;
485 generator.qg = g.qg / base;
486 generator.pmax = g.pmax / base;
487 generator.pmin = g.pmin / base;
488 generator.qmax = g.qmax / base;
489 generator.qmin = g.qmin / base;
490 if let Some(c) = &mut generator.cost {
491 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
492 }
493 for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
495 if GEN_PU_KEYS.contains(&key)
496 && let Some(v) = cap
497 {
498 *v /= base;
499 }
500 }
501 generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
504 Some((generator, Some(row)))
505 })
506 .unzip()
507}
508
509fn norm_switches(
510 switches: &[Switch],
511 base: f64,
512 map: &HashMap<BusId, BusId>,
513) -> (Vec<Switch>, Vec<Option<usize>>) {
514 switches
515 .iter()
516 .enumerate()
517 .filter_map(|(row, s)| {
518 let switch = Switch {
519 from: remap(map, s.from)?,
520 to: remap(map, s.to)?,
521 thermal_rating: s.thermal_rating.map(|v| v / base),
522 pf: s.pf.map(|v| v / base),
523 qf: s.qf.map(|v| v / base),
524 pt: s.pt.map(|v| v / base),
525 qt: s.qt.map(|v| v / base),
526 ..s.clone()
527 };
528 Some((switch, Some(row)))
529 })
530 .unzip()
531}
532
533fn norm_storage(
534 storage: &[Storage],
535 base: f64,
536 map: &HashMap<BusId, BusId>,
537) -> (Vec<Storage>, Vec<Option<usize>>) {
538 storage
539 .iter()
540 .enumerate()
541 .filter(|(_, s)| s.in_service)
542 .filter_map(|(row, s)| {
543 let unit = Storage {
546 bus: remap(map, s.bus)?,
547 energy: s.energy / base,
548 energy_rating: s.energy_rating / base,
549 charge_rating: s.charge_rating / base,
550 discharge_rating: s.discharge_rating / base,
551 thermal_rating: s.thermal_rating / base,
552 qmin: s.qmin / base,
553 qmax: s.qmax / base,
554 p_loss: s.p_loss / base,
555 q_loss: s.q_loss / base,
556 ..s.clone()
557 };
558 Some((unit, Some(row)))
559 })
560 .unzip()
561}
562
563fn norm_hvdc(
564 hvdc: &[Hvdc],
565 base: f64,
566 map: &HashMap<BusId, BusId>,
567) -> (Vec<Hvdc>, Vec<Option<usize>>) {
568 hvdc.iter()
569 .enumerate()
570 .filter(|(_, d)| d.in_service)
571 .filter_map(|(row, d)| {
572 let mut link = d.clone();
576 link.from = remap(map, d.from)?;
577 link.to = remap(map, d.to)?;
578 link.pf = d.pf / base;
579 link.pt = d.pt / base;
580 link.qf = d.qf / base;
581 link.qt = d.qt / base;
582 link.qminf = d.qminf / base;
583 link.qmaxf = d.qmaxf / base;
584 link.qmint = d.qmint / base;
585 link.qmaxt = d.qmaxt / base;
586 link.loss0 = d.loss0 / base;
587 if let Some(c) = &mut link.cost {
588 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
589 }
590 Some((link, Some(row)))
591 })
592 .unzip()
593}
594
595fn norm_transformers_3w(
596 xfmrs: &[Transformer3W],
597 base: f64,
598 map: &HashMap<BusId, BusId>,
599) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
600 xfmrs
601 .iter()
602 .enumerate()
603 .filter(|(_, t)| t.in_service)
604 .filter_map(|(row, t)| {
605 let mut windings = t.windings.clone();
610 for w in &mut windings {
611 w.bus = remap(map, w.bus)?;
612 if let Some(control) = &mut w.control {
613 norm_transformer_control(control, base, map);
614 }
615 w.shift *= DEG_TO_RAD;
616 w.rate_a /= base;
617 w.rate_b /= base;
618 w.rate_c /= base;
619 }
620 Some((
621 Transformer3W {
622 windings,
623 star_va: t.star_va * DEG_TO_RAD,
624 ..t.clone()
625 },
626 Some(row),
627 ))
628 })
629 .unzip()
630}
631
632fn designate_reference(
636 buses: &mut [Bus],
637 generators: &[Generator],
638 warnings: &mut crate::diagnostics::Diagnostics,
639) -> Result<()> {
640 let slack = generators
641 .iter()
642 .max_by(|a, b| {
643 let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
647 key(a.pmax).total_cmp(&key(b.pmax))
648 })
649 .map(|g| g.bus)
650 .ok_or(Error::NoReferenceBus)?;
651 if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
652 b.kind = BusType::Ref;
653 warnings.push(
654 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_REFERENCE_DESIGNATED,
655 format!(
656 "the case states no reference bus that survives normalization; bus {slack} \
657 hosts the largest pmax in-service generator and was designated the slack"
658 ),
659 );
660 }
661 Ok(())
662}
663
664impl BalancedNetwork {
665 pub fn to_normalized(&self) -> Result<BalancedNetwork> {
713 Ok(self
714 .to_normalized_with_options(&NormalizeOptions::default())?
715 .network)
716 }
717
718 pub fn to_normalized_with_options(
721 &self,
722 options: &NormalizeOptions,
723 ) -> Result<NormalizedNetwork> {
724 Ok(self.normalize_inner(options)?.0)
725 }
726
727 #[doc(hidden)]
752 pub fn to_normalized_with_source_rows(
753 &self,
754 options: &NormalizeOptions,
755 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
756 let (normalized, mut rows) = self.normalize_inner(options)?;
757 rows.pad_to_lowered(&normalized.network);
758 Ok((normalized, rows))
759 }
760
761 fn normalize_inner(
765 &self,
766 options: &NormalizeOptions,
767 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
768 validate_normalize_options(options)?;
769 self.check_base_mva()?;
770 let base = self.base_mva();
771
772 let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses().len());
775 let mut buses: Vec<Bus> = Vec::with_capacity(self.buses().len());
776 let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses().len());
780 for (row, b) in self.buses().iter().enumerate() {
781 if b.kind == BusType::Isolated {
782 continue;
783 }
784 id_map.insert(b.id, b.id);
785 buses.push(Bus {
786 va: b.va * DEG_TO_RAD,
787 ..b.clone()
788 });
789 bus_rows.push(Some(row));
790 }
791 let (loads, load_rows) = norm_loads(self.loads(), base, &id_map);
792 let (shunts, shunt_rows) = norm_shunts(self.shunts(), base, &id_map);
793 let (static_var_compensators, static_var_compensator_rows) =
794 norm_static_var_compensators(self.static_var_compensators(), base, &id_map);
795 let (mut branches, branch_rows) = norm_branches(self.branches(), base, &id_map);
796 let mut warnings = crate::diagnostics::Diagnostics::new();
797 if options.clamp_angle_bounds {
798 clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
799 }
800 let (switches, switch_rows) = norm_switches(self.switches(), base, &id_map);
801 let (generators, generator_rows) = norm_gens(self.generators(), base, &id_map);
802 let (storage, storage_rows) = norm_storage(self.storage(), base, &id_map);
803 let (hvdc, hvdc_rows) = norm_hvdc(self.hvdc(), base, &id_map);
804 let (transformers_3w, transformer_3w_rows) =
805 norm_transformers_3w(self.transformers_3w(), base, &id_map);
806 let source_rows = NormalizeSourceRows {
807 buses: bus_rows,
808 loads: load_rows,
809 shunts: shunt_rows,
810 static_var_compensators: static_var_compensator_rows,
811 branches: branch_rows,
812 switches: switch_rows,
813 generators: generator_rows,
814 storage: storage_rows,
815 hvdc: hvdc_rows,
816 transformers_3w: transformer_3w_rows,
817 };
818
819 let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
824 for b in &mut buses {
825 b.kind = match (gen_buses.contains(&b.id), b.kind) {
826 (true, BusType::Ref) => BusType::Ref,
827 (true, _) => BusType::Pv,
828 (false, _) => BusType::Pq,
829 };
830 }
831 if !buses.iter().any(|b| b.kind == BusType::Ref) {
832 designate_reference(&mut buses, &generators, &mut warnings)?;
833 }
834 if !generators.is_empty() && generators.iter().all(|g| g.cost.is_none()) {
837 warnings.push(
838 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GEN_COST_ABSENT,
839 format!(
840 "the case has {} in-service generator(s) and no cost data; any cost \
841 objective built from it is identically zero",
842 generators.len()
843 ),
844 );
845 }
846
847 let net = BalancedNetwork::from_tables(BalancedNetworkTables {
848 name: self.name().clone(),
849 base_mva: base,
850 base_frequency: self.base_frequency(),
851 geo: self.geo().clone(),
852 case_metadata: self.case_metadata().clone(),
853 detailed_connectivity: self.detailed_connectivity().clone(),
854 generated_uids: self.generated_uids().clone(),
855 buses: buses.into(),
856 loads: loads.into(),
857 shunts: shunts.into(),
858 static_var_compensators: static_var_compensators.into(),
859 branches: branches.into(),
860 switches: switches.into(),
861 generators: generators.into(),
862 storage: storage.into(),
863 hvdc: hvdc.into(),
864 transformers_3w: transformers_3w.into(),
865 areas: Vec::new().into(),
868 solver: None,
869 source_format: SourceFormat::Normalized,
870 });
871 debug_assert!(
875 net.validate().is_ok(),
876 "to_normalized produced a dangling reference"
877 );
878 Ok((
879 NormalizedNetwork {
880 network: net,
881 warnings: warnings.lines(),
882 diagnostics: warnings.into_records(),
883 },
884 source_rows,
885 ))
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use crate::network::GeneratorEnergySource;
893
894 fn approx(a: f64, b: f64) -> bool {
895 (a - b).abs() < 1e-9
896 }
897
898 fn angle_bound_fixture() -> BalancedNetwork {
899 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
900 .join("../tests/data/angle_bounds_clamp.m");
901 crate::parse_file(path, None).unwrap().network
902 }
903
904 #[test]
905 fn transformer_control_normalization_follows_psse_field_units_for_every_mode() {
906 let map = HashMap::from([(BusId(7), BusId(7))]);
911 let cases = [
912 (TransformerControlMode::Fixed, false, false),
913 (TransformerControlMode::Voltage, false, false),
914 (TransformerControlMode::ReactiveFlow, false, true),
915 (TransformerControlMode::ActiveFlow, true, true),
916 (TransformerControlMode::DcLineQuantity, false, false),
917 (TransformerControlMode::AsymmetricActiveFlow, true, true),
918 ];
919
920 for (mode, angle_limits, power_band) in cases {
921 let mut control = TransformerControl::new(mode);
922 control.controlled_bus = Some(BusId(7));
923 control.tap_min = -10.0;
924 control.tap_max = 20.0;
925 control.band_min = -50.0;
926 control.band_max = 75.0;
927 control.winding_connection_angle =
928 (mode == TransformerControlMode::AsymmetricActiveFlow).then_some(30.0);
929
930 norm_transformer_control(&mut control, 100.0, &map);
931
932 let tap_scale = if angle_limits { DEG_TO_RAD } else { 1.0 };
933 let band_scale = if power_band { 0.01 } else { 1.0 };
934 assert!(approx(control.tap_min, -10.0 * tap_scale), "{mode:?}");
935 assert!(approx(control.tap_max, 20.0 * tap_scale), "{mode:?}");
936 assert!(approx(control.band_min, -50.0 * band_scale), "{mode:?}");
937 assert!(approx(control.band_max, 75.0 * band_scale), "{mode:?}");
938 assert_eq!(control.controlled_bus, Some(BusId(7)), "{mode:?}");
939 if mode == TransformerControlMode::AsymmetricActiveFlow {
940 assert!(approx(
941 control.winding_connection_angle.unwrap(),
942 30.0 * DEG_TO_RAD
943 ));
944 } else {
945 assert_eq!(control.winding_connection_angle, None, "{mode:?}");
946 }
947 }
948 }
949
950 #[test]
951 fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
952 let net = angle_bound_fixture();
953
954 let plain = net.to_normalized().unwrap();
955 assert!(approx(plain.branches()[0].angmin, -std::f64::consts::TAU));
956 assert!(approx(plain.branches()[0].angmax, std::f64::consts::TAU));
957 assert!(approx(plain.branches()[1].angmin, 0.0));
958 assert!(approx(plain.branches()[1].angmax, 0.0));
959 assert!(approx(plain.branches()[3].angmin, -120.0 * DEG_TO_RAD));
960 assert!(approx(plain.branches()[3].angmax, -100.0 * DEG_TO_RAD));
961 assert!(approx(plain.branches()[4].angmin, 100.0 * DEG_TO_RAD));
962 assert!(approx(plain.branches()[4].angmax, 120.0 * DEG_TO_RAD));
963
964 let out = net
965 .to_normalized_with_options(&NormalizeOptions {
966 clamp_angle_bounds: true,
967 ..NormalizeOptions::default()
968 })
969 .unwrap();
970 let clamps: Vec<&String> = out
973 .warnings
974 .iter()
975 .filter(|w| w.contains("BOUNDS_CLAMPED"))
976 .collect();
977 assert_eq!(clamps.len(), 4, "{:?}", out.warnings);
978 assert!(clamps[0].contains("branch 0"));
979 assert!(clamps[1].contains("branch 1"));
980 assert!(clamps[2].contains("branch 3"));
981 assert!(clamps[3].contains("branch 4"));
982
983 let branches = &out.network.branches();
984 assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
985 assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
986 assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
987 assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
988 assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
989 assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
990 assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
991 assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
992 assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
993 assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
994 assert!(branches.iter().all(|br| br.angmin <= br.angmax));
995 }
996
997 #[test]
998 fn angle_bound_clamp_rejects_invalid_pad() {
999 let net = angle_bound_fixture();
1000 let err = net
1001 .to_normalized_with_options(&NormalizeOptions {
1002 clamp_angle_bounds: true,
1003 angle_bound_pad: std::f64::consts::FRAC_PI_2,
1004 })
1005 .unwrap_err();
1006 assert!(matches!(
1007 err,
1008 Error::InvalidNormalizeOption {
1009 field: "angle_bound_pad",
1010 ..
1011 }
1012 ));
1013 }
1014
1015 #[test]
1016 fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
1017 use crate::network::{Extras, ShuntBlock, SwitchedShuntControl, SwitchedShuntMode};
1018
1019 let mkbus = |id: usize, kind: BusType| Bus {
1020 id: BusId(id),
1021 kind,
1022 vm: 1.0,
1023 va: 0.0,
1024 base_kv: 230.0,
1025 vmax: 1.1,
1026 vmin: 0.9,
1027 evhi: None,
1028 evlo: None,
1029 area: 1,
1030 zone: 1,
1031 name: None,
1032 uid: None,
1033 location: None,
1034 extras: Extras::new(),
1035 };
1036 let branch = Branch {
1037 name: None,
1038 from: BusId(1),
1039 to: BusId(2),
1040 r: 0.0,
1041 x: 0.1,
1042 b: 0.0,
1043 charging: None,
1044 rate_a: 0.0,
1045 rate_b: 0.0,
1046 rate_c: 0.0,
1047 rating_sets: Vec::new(),
1048 current_ratings: None,
1049 tap: 0.0,
1050 shift: 0.0,
1051 in_service: true,
1052 angmin: -360.0,
1053 angmax: 360.0,
1054 control: None,
1055 solution: None,
1056 uid: None,
1057 route: None,
1058 extras: Extras::new(),
1059 };
1060 let mut net = BalancedNetwork::in_memory(
1062 "n",
1063 100.0,
1064 vec![
1065 mkbus(1, BusType::Ref),
1066 mkbus(2, BusType::Pq),
1067 mkbus(3, BusType::Isolated),
1068 ],
1069 vec![branch],
1070 );
1071 net.generators_mut().push(Generator {
1072 bus: BusId(1),
1073 energy_source: GeneratorEnergySource::default(),
1074 pg: 10.0,
1075 qg: 0.0,
1076 pmax: 100.0,
1077 pmin: 0.0,
1078 qmax: 50.0,
1079 qmin: -50.0,
1080 vg: 1.0,
1081 mbase: 100.0,
1082 in_service: true,
1083 cost: None,
1084 caps: Default::default(),
1085 voltage_regulation_on: true,
1086 regulating_terminal: None,
1087 regulated_bus: None,
1088 active_power_control: None,
1089 uid: None,
1090 });
1091 net.shunts_mut().push(Shunt {
1093 bus: BusId(2),
1094 g: 0.0,
1095 b: 10.0,
1096 in_service: true,
1097 section_count: None,
1098 control: Some(SwitchedShuntControl {
1099 mode: SwitchedShuntMode::Discrete,
1100 vhigh: 1.05,
1101 vlow: 0.95,
1102 control_bus: Some(BusId(3)),
1103 regulating_terminal: None,
1104 rmpct: 100.0,
1105 blocks: vec![ShuntBlock::with_admittance(2, 4.0, 20.0)],
1106 }),
1107 uid: None,
1108 extras: Extras::new(),
1109 });
1110
1111 let norm = net.to_normalized().unwrap();
1112 norm.validate().unwrap();
1113 let c = norm.shunts()[0].control.as_ref().expect("control retained");
1114 assert_eq!(
1115 c.control_bus, None,
1116 "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
1117 );
1118 assert!(approx(c.blocks[0].g, 0.04));
1119 assert!(approx(c.blocks[0].b, 0.2));
1120 }
1121
1122 #[test]
1123 fn normalized_slack_tiebreak_ignores_nan_pmax() {
1124 use crate::network::Extras;
1125
1126 let mkbus = |id: usize| Bus {
1127 id: BusId(id),
1128 kind: BusType::Pq,
1129 vm: 1.0,
1130 va: 0.0,
1131 base_kv: 230.0,
1132 vmax: 1.1,
1133 vmin: 0.9,
1134 evhi: None,
1135 evlo: None,
1136 area: 1,
1137 zone: 1,
1138 name: None,
1139 uid: None,
1140 location: None,
1141 extras: Extras::new(),
1142 };
1143 let mkgen = |bus: usize, pmax: f64| Generator {
1144 bus: BusId(bus),
1145 energy_source: GeneratorEnergySource::default(),
1146 pg: 0.0,
1147 qg: 0.0,
1148 pmax,
1149 pmin: 0.0,
1150 qmax: 0.0,
1151 qmin: 0.0,
1152 vg: 1.0,
1153 mbase: 100.0,
1154 in_service: true,
1155 cost: None,
1156 caps: Default::default(),
1157 voltage_regulation_on: true,
1158 regulating_terminal: None,
1159 regulated_bus: None,
1160 active_power_control: None,
1161 uid: None,
1162 };
1163 let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
1164 *net.generators_mut() = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
1165 let norm = net.to_normalized().unwrap();
1166
1167 assert_eq!(
1168 norm.buses().iter().find(|b| b.id == BusId(1)).unwrap().kind,
1169 BusType::Pv
1170 );
1171 assert_eq!(
1172 norm.buses().iter().find(|b| b.id == BusId(2)).unwrap().kind,
1173 BusType::Ref
1174 );
1175 }
1176
1177 #[test]
1178 fn cost_to_pu_polynomial_scales_and_trims() {
1179 let cost = GenCost {
1182 model: 2,
1183 startup: 0.0,
1184 shutdown: 0.0,
1185 ncost: 2,
1186 coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1187 };
1188 let out = cost_to_pu(&cost, 100.0);
1189 assert_eq!(out.len(), 2, "padding dropped");
1190 assert!(approx(out[0], 2403.5)); assert!(approx(out[1], -403.5)); }
1193
1194 #[test]
1195 fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1196 let cost = GenCost {
1198 model: 1,
1199 startup: 0.0,
1200 shutdown: 0.0,
1201 ncost: 4,
1202 coeffs: vec![
1203 0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1204 ],
1205 };
1206 let out = cost_to_pu(&cost, 100.0);
1207 assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1208 assert!(
1209 approx(out[0], 0.0)
1210 && approx(out[2], 1.0)
1211 && approx(out[4], 2.0)
1212 && approx(out[6], 2.5)
1213 );
1214 assert!(
1215 approx(out[1], 0.0)
1216 && approx(out[3], 2500.0)
1217 && approx(out[5], 5500.0)
1218 && approx(out[7], 7250.0)
1219 );
1220 }
1221
1222 #[test]
1223 fn cost_rescale_round_trips() {
1224 let cost = GenCost {
1226 model: 2,
1227 startup: 0.0,
1228 shutdown: 0.0,
1229 ncost: 3,
1230 coeffs: vec![0.11, 5.0, 150.0],
1231 };
1232 let pu = cost_to_pu(&cost, 100.0);
1233 assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1235 assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1236 assert!((pu[2] - 150.0).abs() < 1e-9);
1237 let back = cost_from_pu(&pu, 2, 100.0);
1238 for (a, b) in back.iter().zip(&cost.coeffs) {
1239 assert!((a - b).abs() < 1e-9);
1240 }
1241 }
1242
1243 #[test]
1244 fn cost_rescale_passes_through_unknown_model() {
1245 let cost = GenCost {
1249 model: 0,
1250 startup: 0.0,
1251 shutdown: 0.0,
1252 ncost: 2,
1253 coeffs: vec![3.0, 7.0, 9.0],
1254 };
1255 let pu = cost_to_pu(&cost, 100.0);
1256 assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1257 let back = cost_from_pu(&pu, cost.model, 100.0);
1258 assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1259 }
1260
1261 #[test]
1262 fn cost_rescale_round_trips_piecewise() {
1263 let cost = GenCost {
1267 model: 1,
1268 startup: 0.0,
1269 shutdown: 0.0,
1270 ncost: 4,
1271 coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1272 };
1273 let pu = cost_to_pu(&cost, 100.0);
1274 let back = cost_from_pu(&pu, 1, 100.0);
1275 for (a, b) in back.iter().zip(&cost.coeffs) {
1276 assert!((a - b).abs() < 1e-9, "{a} != {b}");
1277 }
1278 }
1279}