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, Storage, Switch, Transformer3W,
20};
21use crate::{Error, Result};
22
23pub(crate) const DEG_TO_RAD: f64 = std::f64::consts::PI / 180.0;
26
27pub(crate) const RAD_TO_DEG: f64 = 180.0 / std::f64::consts::PI;
30
31pub(crate) const GEN_PU_KEYS: [&str; 4] = ["ramp_agc", "ramp_10", "ramp_30", "ramp_q"];
36
37#[allow(clippy::approx_constant)]
39pub const POWER_MODELS_ANGLE_BOUND_PAD: f64 = 1.0472;
40
41#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct NormalizeOptions {
44 pub clamp_angle_bounds: bool,
47 pub angle_bound_pad: f64,
49}
50
51impl Default for NormalizeOptions {
52 fn default() -> Self {
53 Self {
54 clamp_angle_bounds: false,
55 angle_bound_pad: POWER_MODELS_ANGLE_BOUND_PAD,
56 }
57 }
58}
59
60#[derive(Clone, Debug)]
62#[doc(hidden)]
66pub struct NormalizedNetwork {
67 pub network: BalancedNetwork,
68 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
70 pub warnings: Vec<String>,
72}
73
74#[derive(Clone, Debug)]
96#[non_exhaustive]
97pub struct NormalizeSourceRows {
98 pub buses: Vec<Option<usize>>,
99 pub loads: Vec<Option<usize>>,
100 pub shunts: Vec<Option<usize>>,
101 pub branches: Vec<Option<usize>>,
102 pub switches: Vec<Option<usize>>,
103 pub generators: Vec<Option<usize>>,
104 pub storage: Vec<Option<usize>>,
105 pub hvdc: Vec<Option<usize>>,
106 pub transformers_3w: Vec<Option<usize>>,
107}
108
109impl NormalizeSourceRows {
110 pub(crate) fn identity(net: &BalancedNetwork) -> Self {
114 let ident = |n: usize| (0..n).map(Some).collect();
115 Self {
116 buses: ident(net.buses().len()),
117 loads: ident(net.loads().len()),
118 shunts: ident(net.shunts().len()),
119 branches: ident(net.branches().len()),
120 switches: ident(net.switches().len()),
121 generators: ident(net.generators().len()),
122 storage: ident(net.storage().len()),
123 hvdc: ident(net.hvdc().len()),
124 transformers_3w: ident(net.transformers_3w().len()),
125 }
126 }
127
128 pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
133 let lengths = net.lowered_lengths();
134 self.buses.resize(lengths.buses, None);
135 self.branches.resize(lengths.branches, None);
136 self.shunts.resize(lengths.shunts, None);
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141enum CostModel {
142 Piecewise,
143 Polynomial,
144 Unknown,
145}
146
147impl From<u8> for CostModel {
148 fn from(value: u8) -> Self {
149 match value {
150 1 => CostModel::Piecewise,
151 2 => CostModel::Polynomial,
152 _ => CostModel::Unknown,
153 }
154 }
155}
156
157pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
169 let mut coeffs = cost.coeffs.clone();
170 scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
171 coeffs
172}
173
174pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
177 match CostModel::from(model) {
178 CostModel::Polynomial => {
179 coeffs.truncate(ncost.min(coeffs.len()));
180 let k = coeffs.len();
181 for (i, c) in coeffs.iter_mut().enumerate() {
184 *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
185 }
186 }
187 CostModel::Piecewise => {
188 coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
192 for c in coeffs.iter_mut().step_by(2) {
193 *c /= base;
194 }
195 }
196 CostModel::Unknown => {}
197 }
198}
199
200pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
206 let k = coeffs.len();
207 match CostModel::from(model) {
208 CostModel::Polynomial => coeffs
209 .iter()
210 .enumerate()
211 .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
212 .collect(),
213 CostModel::Piecewise => coeffs
214 .iter()
215 .enumerate()
216 .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
217 .collect(),
218 CostModel::Unknown => coeffs.to_vec(),
219 }
220}
221
222fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
224 map.get(&id).copied()
225}
226
227fn norm_loads(
228 loads: &[Load],
229 base: f64,
230 map: &HashMap<BusId, BusId>,
231) -> (Vec<Load>, Vec<Option<usize>>) {
232 loads
233 .iter()
234 .enumerate()
235 .filter(|(_, l)| l.in_service)
236 .filter_map(|(row, l)| {
237 Some((
238 Load {
239 bus: remap(map, l.bus)?,
240 p: l.p / base,
241 q: l.q / base,
242 voltage_model: l
243 .voltage_model
244 .as_ref()
245 .map(|m| norm_load_voltage_model(m, base)),
246 ..l.clone()
247 },
248 Some(row),
249 ))
250 })
251 .unzip()
252}
253
254fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
255 match model {
256 LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
257 LoadVoltageModel::Zip {
258 p_constant_power,
259 q_constant_power,
260 p_constant_current,
261 q_constant_current,
262 p_constant_impedance,
263 q_constant_impedance,
264 v_nom,
265 load_type,
266 scaling,
267 } => LoadVoltageModel::Zip {
268 p_constant_power: p_constant_power / base,
269 q_constant_power: q_constant_power / base,
270 p_constant_current: p_constant_current / base,
271 q_constant_current: q_constant_current / base,
272 p_constant_impedance: p_constant_impedance / base,
273 q_constant_impedance: q_constant_impedance / base,
274 v_nom: *v_nom,
275 load_type: *load_type,
276 scaling: *scaling,
277 },
278 LoadVoltageModel::Exponential {
279 p,
280 q,
281 v_nom,
282 gamma_p,
283 gamma_q,
284 } => LoadVoltageModel::Exponential {
285 p: p / base,
286 q: q / base,
287 v_nom: *v_nom,
288 gamma_p: *gamma_p,
289 gamma_q: *gamma_q,
290 },
291 }
292}
293
294fn norm_shunts(
295 shunts: &[Shunt],
296 base: f64,
297 map: &HashMap<BusId, BusId>,
298) -> (Vec<Shunt>, Vec<Option<usize>>) {
299 shunts
300 .iter()
301 .enumerate()
302 .filter(|(_, s)| s.in_service)
303 .filter_map(|(row, s)| {
304 let mut shunt = s.clone();
305 shunt.bus = remap(map, s.bus)?;
306 shunt.g = s.g / base;
307 shunt.b = s.b / base;
308 if let Some(c) = &mut shunt.control {
311 c.control_bus = c.control_bus.and_then(|b| remap(map, b));
312 }
313 Some((shunt, Some(row)))
314 })
315 .unzip()
316}
317
318fn norm_branches(
319 branches: &[Branch],
320 base: f64,
321 map: &HashMap<BusId, BusId>,
322) -> (Vec<Branch>, Vec<Option<usize>>) {
323 branches
324 .iter()
325 .enumerate()
326 .filter(|(_, br)| br.in_service)
327 .filter_map(|(row, br)| {
328 let mut branch = br.clone();
329 branch.from = remap(map, br.from)?;
330 branch.to = remap(map, br.to)?;
331 branch.rate_a = br.rate_a / base;
332 branch.rate_b = br.rate_b / base;
333 branch.rate_c = br.rate_c / base;
334 for set in &mut branch.rating_sets {
335 set.rate_mva /= base;
336 }
337 branch.tap = br.effective_tap();
338 branch.shift = br.shift * DEG_TO_RAD;
339 branch.angmin = br.angmin * DEG_TO_RAD;
340 branch.angmax = br.angmax * DEG_TO_RAD;
341 if let Some(s) = &mut branch.solution {
342 s.pf /= base;
343 s.qf /= base;
344 s.pt /= base;
345 s.qt /= base;
346 }
347 if let Some(c) = &mut branch.control {
351 c.controlled_bus = c.controlled_bus.and_then(|b| remap(map, b));
352 }
353 Some((branch, Some(row)))
354 })
355 .unzip()
356}
357
358fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
359 if options.clamp_angle_bounds
360 && (!options.angle_bound_pad.is_finite()
361 || options.angle_bound_pad <= 0.0
362 || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
363 {
364 return Err(Error::InvalidNormalizeOption {
365 field: "angle_bound_pad",
366 value: options.angle_bound_pad,
367 });
368 }
369 Ok(())
370}
371
372fn clamp_angle_bounds(
373 branches: &mut [Branch],
374 pad: f64,
375 warnings: &mut crate::diagnostics::Diagnostics,
376) {
377 for (idx, br) in branches.iter_mut().enumerate() {
378 let old_min = br.angmin;
379 let old_max = br.angmax;
380 let mut changes = Vec::new();
381
382 if old_min <= -std::f64::consts::FRAC_PI_2 {
383 br.angmin = -pad;
384 changes.push(format!("angmin {old_min} -> {}", br.angmin));
385 }
386 if old_max >= std::f64::consts::FRAC_PI_2 {
387 br.angmax = pad;
388 changes.push(format!("angmax {old_max} -> {}", br.angmax));
389 }
390 if old_min == 0.0 && old_max == 0.0 {
391 br.angmin = -pad;
392 br.angmax = pad;
393 changes.push(format!("angmin/angmax 0 -> [{}, {}]", br.angmin, br.angmax));
394 }
395 if !changes.is_empty() && br.angmin > br.angmax {
396 let repaired_min = br.angmin;
397 let repaired_max = br.angmax;
398 br.angmin = -pad;
399 br.angmax = pad;
400 changes.push(format!(
401 "repaired interval {repaired_min}..{repaired_max} widened to [{}, {}]",
402 br.angmin, br.angmax
403 ));
404 }
405
406 if !changes.is_empty() {
407 warnings.push(
408 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_BOUNDS_CLAMPED,
409 format!(
410 "branch {idx} angle difference bounds clamped: {}",
411 changes.join(", ")
412 ),
413 );
414 }
415 }
416}
417
418fn norm_gens(
419 gens: &[Generator],
420 base: f64,
421 map: &HashMap<BusId, BusId>,
422) -> (Vec<Generator>, Vec<Option<usize>>) {
423 gens.iter()
424 .enumerate()
425 .filter(|(_, g)| g.in_service)
426 .filter_map(|(row, g)| {
427 let mut generator = g.clone();
428 generator.bus = remap(map, g.bus)?;
429 generator.pg = g.pg / base;
430 generator.qg = g.qg / base;
431 generator.pmax = g.pmax / base;
432 generator.pmin = g.pmin / base;
433 generator.qmax = g.qmax / base;
434 generator.qmin = g.qmin / base;
435 if let Some(c) = &mut generator.cost {
436 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
437 }
438 for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
440 if GEN_PU_KEYS.contains(&key)
441 && let Some(v) = cap
442 {
443 *v /= base;
444 }
445 }
446 generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
449 Some((generator, Some(row)))
450 })
451 .unzip()
452}
453
454fn norm_switches(
455 switches: &[Switch],
456 base: f64,
457 map: &HashMap<BusId, BusId>,
458) -> (Vec<Switch>, Vec<Option<usize>>) {
459 switches
460 .iter()
461 .enumerate()
462 .filter_map(|(row, s)| {
463 let switch = Switch {
464 from: remap(map, s.from)?,
465 to: remap(map, s.to)?,
466 thermal_rating: s.thermal_rating.map(|v| v / base),
467 pf: s.pf.map(|v| v / base),
468 qf: s.qf.map(|v| v / base),
469 pt: s.pt.map(|v| v / base),
470 qt: s.qt.map(|v| v / base),
471 ..s.clone()
472 };
473 Some((switch, Some(row)))
474 })
475 .unzip()
476}
477
478fn norm_storage(
479 storage: &[Storage],
480 base: f64,
481 map: &HashMap<BusId, BusId>,
482) -> (Vec<Storage>, Vec<Option<usize>>) {
483 storage
484 .iter()
485 .enumerate()
486 .filter(|(_, s)| s.in_service)
487 .filter_map(|(row, s)| {
488 let unit = Storage {
491 bus: remap(map, s.bus)?,
492 energy: s.energy / base,
493 energy_rating: s.energy_rating / base,
494 charge_rating: s.charge_rating / base,
495 discharge_rating: s.discharge_rating / base,
496 thermal_rating: s.thermal_rating / base,
497 qmin: s.qmin / base,
498 qmax: s.qmax / base,
499 p_loss: s.p_loss / base,
500 q_loss: s.q_loss / base,
501 ..s.clone()
502 };
503 Some((unit, Some(row)))
504 })
505 .unzip()
506}
507
508fn norm_hvdc(
509 hvdc: &[Hvdc],
510 base: f64,
511 map: &HashMap<BusId, BusId>,
512) -> (Vec<Hvdc>, Vec<Option<usize>>) {
513 hvdc.iter()
514 .enumerate()
515 .filter(|(_, d)| d.in_service)
516 .filter_map(|(row, d)| {
517 let mut link = d.clone();
521 link.from = remap(map, d.from)?;
522 link.to = remap(map, d.to)?;
523 link.pf = d.pf / base;
524 link.pt = d.pt / base;
525 link.qf = d.qf / base;
526 link.qt = d.qt / base;
527 link.qminf = d.qminf / base;
528 link.qmaxf = d.qmaxf / base;
529 link.qmint = d.qmint / base;
530 link.qmaxt = d.qmaxt / base;
531 link.loss0 = d.loss0 / base;
532 if let Some(c) = &mut link.cost {
533 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
534 }
535 Some((link, Some(row)))
536 })
537 .unzip()
538}
539
540fn norm_transformers_3w(
541 xfmrs: &[Transformer3W],
542 base: f64,
543 map: &HashMap<BusId, BusId>,
544) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
545 xfmrs
546 .iter()
547 .enumerate()
548 .filter(|(_, t)| t.in_service)
549 .filter_map(|(row, t)| {
550 let mut windings = t.windings.clone();
555 for w in &mut windings {
556 w.bus = remap(map, w.bus)?;
557 w.shift *= DEG_TO_RAD;
558 w.rate_a /= base;
559 w.rate_b /= base;
560 w.rate_c /= base;
561 }
562 Some((
563 Transformer3W {
564 windings,
565 star_va: t.star_va * DEG_TO_RAD,
566 ..t.clone()
567 },
568 Some(row),
569 ))
570 })
571 .unzip()
572}
573
574fn designate_reference(
578 buses: &mut [Bus],
579 generators: &[Generator],
580 warnings: &mut crate::diagnostics::Diagnostics,
581) -> Result<()> {
582 let slack = generators
583 .iter()
584 .max_by(|a, b| {
585 let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
589 key(a.pmax).total_cmp(&key(b.pmax))
590 })
591 .map(|g| g.bus)
592 .ok_or(Error::NoReferenceBus)?;
593 if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
594 b.kind = BusType::Ref;
595 warnings.push(
596 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_REFERENCE_DESIGNATED,
597 format!(
598 "the case states no reference bus that survives normalization; bus {slack} \
599 hosts the largest pmax in-service generator and was designated the slack"
600 ),
601 );
602 }
603 Ok(())
604}
605
606impl BalancedNetwork {
607 pub fn to_normalized(&self) -> Result<BalancedNetwork> {
655 Ok(self
656 .to_normalized_with_options(&NormalizeOptions::default())?
657 .network)
658 }
659
660 pub fn to_normalized_with_options(
663 &self,
664 options: &NormalizeOptions,
665 ) -> Result<NormalizedNetwork> {
666 Ok(self.normalize_inner(options)?.0)
667 }
668
669 #[doc(hidden)]
694 pub fn to_normalized_with_source_rows(
695 &self,
696 options: &NormalizeOptions,
697 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
698 let (normalized, mut rows) = self.normalize_inner(options)?;
699 rows.pad_to_lowered(&normalized.network);
700 Ok((normalized, rows))
701 }
702
703 fn normalize_inner(
707 &self,
708 options: &NormalizeOptions,
709 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
710 validate_normalize_options(options)?;
711 self.check_base_mva()?;
712 let base = self.base_mva();
713
714 let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses().len());
717 let mut buses: Vec<Bus> = Vec::with_capacity(self.buses().len());
718 let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses().len());
722 for (row, b) in self.buses().iter().enumerate() {
723 if b.kind == BusType::Isolated {
724 continue;
725 }
726 id_map.insert(b.id, b.id);
727 buses.push(Bus {
728 va: b.va * DEG_TO_RAD,
729 ..b.clone()
730 });
731 bus_rows.push(Some(row));
732 }
733 let (loads, load_rows) = norm_loads(self.loads(), base, &id_map);
734 let (shunts, shunt_rows) = norm_shunts(self.shunts(), base, &id_map);
735 let (mut branches, branch_rows) = norm_branches(self.branches(), base, &id_map);
736 let mut warnings = crate::diagnostics::Diagnostics::new();
737 if options.clamp_angle_bounds {
738 clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
739 }
740 let (switches, switch_rows) = norm_switches(self.switches(), base, &id_map);
741 let (generators, generator_rows) = norm_gens(self.generators(), base, &id_map);
742 let (storage, storage_rows) = norm_storage(self.storage(), base, &id_map);
743 let (hvdc, hvdc_rows) = norm_hvdc(self.hvdc(), base, &id_map);
744 let (transformers_3w, transformer_3w_rows) =
745 norm_transformers_3w(self.transformers_3w(), base, &id_map);
746 let source_rows = NormalizeSourceRows {
747 buses: bus_rows,
748 loads: load_rows,
749 shunts: shunt_rows,
750 branches: branch_rows,
751 switches: switch_rows,
752 generators: generator_rows,
753 storage: storage_rows,
754 hvdc: hvdc_rows,
755 transformers_3w: transformer_3w_rows,
756 };
757
758 let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
763 for b in &mut buses {
764 b.kind = match (gen_buses.contains(&b.id), b.kind) {
765 (true, BusType::Ref) => BusType::Ref,
766 (true, _) => BusType::Pv,
767 (false, _) => BusType::Pq,
768 };
769 }
770 if !buses.iter().any(|b| b.kind == BusType::Ref) {
771 designate_reference(&mut buses, &generators, &mut warnings)?;
772 }
773 if !generators.is_empty() && generators.iter().all(|g| g.cost.is_none()) {
776 warnings.push(
777 &crate::diagnostics::codes::CANONICALIZE_NORMALIZE_GEN_COST_ABSENT,
778 format!(
779 "the case has {} in-service generator(s) and no cost data; any cost \
780 objective built from it is identically zero",
781 generators.len()
782 ),
783 );
784 }
785
786 let net = BalancedNetwork::from_tables(BalancedNetworkTables {
787 name: self.name().clone(),
788 base_mva: base,
789 base_frequency: self.base_frequency(),
790 geo: self.geo().clone(),
791 buses: buses.into(),
792 loads: loads.into(),
793 shunts: shunts.into(),
794 branches: branches.into(),
795 switches: switches.into(),
796 generators: generators.into(),
797 storage: storage.into(),
798 hvdc: hvdc.into(),
799 transformers_3w: transformers_3w.into(),
800 areas: Vec::new().into(),
803 solver: None,
804 source_format: SourceFormat::Normalized,
805 });
806 debug_assert!(
810 net.validate().is_ok(),
811 "to_normalized produced a dangling reference"
812 );
813 Ok((
814 NormalizedNetwork {
815 network: net,
816 warnings: warnings.lines(),
817 diagnostics: warnings.into_records(),
818 },
819 source_rows,
820 ))
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn approx(a: f64, b: f64) -> bool {
829 (a - b).abs() < 1e-9
830 }
831
832 fn angle_bound_fixture() -> BalancedNetwork {
833 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
834 .join("../tests/data/angle_bounds_clamp.m");
835 crate::parse_file(path, None).unwrap().network
836 }
837
838 #[test]
839 fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
840 let net = angle_bound_fixture();
841
842 let plain = net.to_normalized().unwrap();
843 assert!(approx(plain.branches()[0].angmin, -std::f64::consts::TAU));
844 assert!(approx(plain.branches()[0].angmax, std::f64::consts::TAU));
845 assert!(approx(plain.branches()[1].angmin, 0.0));
846 assert!(approx(plain.branches()[1].angmax, 0.0));
847 assert!(approx(plain.branches()[3].angmin, -120.0 * DEG_TO_RAD));
848 assert!(approx(plain.branches()[3].angmax, -100.0 * DEG_TO_RAD));
849 assert!(approx(plain.branches()[4].angmin, 100.0 * DEG_TO_RAD));
850 assert!(approx(plain.branches()[4].angmax, 120.0 * DEG_TO_RAD));
851
852 let out = net
853 .to_normalized_with_options(&NormalizeOptions {
854 clamp_angle_bounds: true,
855 ..NormalizeOptions::default()
856 })
857 .unwrap();
858 let clamps: Vec<&String> = out
861 .warnings
862 .iter()
863 .filter(|w| w.contains("BOUNDS_CLAMPED"))
864 .collect();
865 assert_eq!(clamps.len(), 4, "{:?}", out.warnings);
866 assert!(clamps[0].contains("branch 0"));
867 assert!(clamps[1].contains("branch 1"));
868 assert!(clamps[2].contains("branch 3"));
869 assert!(clamps[3].contains("branch 4"));
870
871 let branches = &out.network.branches();
872 assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
873 assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
874 assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
875 assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
876 assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
877 assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
878 assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
879 assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
880 assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
881 assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
882 assert!(branches.iter().all(|br| br.angmin <= br.angmax));
883 }
884
885 #[test]
886 fn angle_bound_clamp_rejects_invalid_pad() {
887 let net = angle_bound_fixture();
888 let err = net
889 .to_normalized_with_options(&NormalizeOptions {
890 clamp_angle_bounds: true,
891 angle_bound_pad: std::f64::consts::FRAC_PI_2,
892 })
893 .unwrap_err();
894 assert!(matches!(
895 err,
896 Error::InvalidNormalizeOption {
897 field: "angle_bound_pad",
898 ..
899 }
900 ));
901 }
902
903 #[test]
904 fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
905 use crate::network::{Extras, SwitchedShuntControl, SwitchedShuntMode};
906
907 let mkbus = |id: usize, kind: BusType| Bus {
908 id: BusId(id),
909 kind,
910 vm: 1.0,
911 va: 0.0,
912 base_kv: 230.0,
913 vmax: 1.1,
914 vmin: 0.9,
915 evhi: None,
916 evlo: None,
917 area: 1,
918 zone: 1,
919 name: None,
920 uid: None,
921 location: None,
922 extras: Extras::new(),
923 };
924 let branch = Branch {
925 from: BusId(1),
926 to: BusId(2),
927 r: 0.0,
928 x: 0.1,
929 b: 0.0,
930 charging: None,
931 rate_a: 0.0,
932 rate_b: 0.0,
933 rate_c: 0.0,
934 rating_sets: Vec::new(),
935 current_ratings: None,
936 tap: 0.0,
937 shift: 0.0,
938 in_service: true,
939 angmin: -360.0,
940 angmax: 360.0,
941 control: None,
942 solution: None,
943 uid: None,
944 route: None,
945 extras: Extras::new(),
946 };
947 let mut net = BalancedNetwork::in_memory(
949 "n",
950 100.0,
951 vec![
952 mkbus(1, BusType::Ref),
953 mkbus(2, BusType::Pq),
954 mkbus(3, BusType::Isolated),
955 ],
956 vec![branch],
957 );
958 net.generators_mut().push(Generator {
959 bus: BusId(1),
960 pg: 10.0,
961 qg: 0.0,
962 pmax: 100.0,
963 pmin: 0.0,
964 qmax: 50.0,
965 qmin: -50.0,
966 vg: 1.0,
967 mbase: 100.0,
968 in_service: true,
969 cost: None,
970 caps: Default::default(),
971 regulated_bus: None,
972 uid: None,
973 });
974 net.shunts_mut().push(Shunt {
976 bus: BusId(2),
977 g: 0.0,
978 b: 10.0,
979 in_service: true,
980 control: Some(SwitchedShuntControl {
981 mode: SwitchedShuntMode::Discrete,
982 vhigh: 1.05,
983 vlow: 0.95,
984 control_bus: Some(BusId(3)),
985 rmpct: 100.0,
986 blocks: Vec::new(),
987 }),
988 uid: None,
989 extras: Extras::new(),
990 });
991
992 let norm = net.to_normalized().unwrap();
993 norm.validate().unwrap();
994 let c = norm.shunts()[0].control.as_ref().expect("control retained");
995 assert_eq!(
996 c.control_bus, None,
997 "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
998 );
999 }
1000
1001 #[test]
1002 fn normalized_slack_tiebreak_ignores_nan_pmax() {
1003 use crate::network::Extras;
1004
1005 let mkbus = |id: usize| Bus {
1006 id: BusId(id),
1007 kind: BusType::Pq,
1008 vm: 1.0,
1009 va: 0.0,
1010 base_kv: 230.0,
1011 vmax: 1.1,
1012 vmin: 0.9,
1013 evhi: None,
1014 evlo: None,
1015 area: 1,
1016 zone: 1,
1017 name: None,
1018 uid: None,
1019 location: None,
1020 extras: Extras::new(),
1021 };
1022 let mkgen = |bus: usize, pmax: f64| Generator {
1023 bus: BusId(bus),
1024 pg: 0.0,
1025 qg: 0.0,
1026 pmax,
1027 pmin: 0.0,
1028 qmax: 0.0,
1029 qmin: 0.0,
1030 vg: 1.0,
1031 mbase: 100.0,
1032 in_service: true,
1033 cost: None,
1034 caps: Default::default(),
1035 regulated_bus: None,
1036 uid: None,
1037 };
1038 let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
1039 *net.generators_mut() = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
1040 let norm = net.to_normalized().unwrap();
1041
1042 assert_eq!(
1043 norm.buses().iter().find(|b| b.id == BusId(1)).unwrap().kind,
1044 BusType::Pv
1045 );
1046 assert_eq!(
1047 norm.buses().iter().find(|b| b.id == BusId(2)).unwrap().kind,
1048 BusType::Ref
1049 );
1050 }
1051
1052 #[test]
1053 fn cost_to_pu_polynomial_scales_and_trims() {
1054 let cost = GenCost {
1057 model: 2,
1058 startup: 0.0,
1059 shutdown: 0.0,
1060 ncost: 2,
1061 coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1062 };
1063 let out = cost_to_pu(&cost, 100.0);
1064 assert_eq!(out.len(), 2, "padding dropped");
1065 assert!(approx(out[0], 2403.5)); assert!(approx(out[1], -403.5)); }
1068
1069 #[test]
1070 fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1071 let cost = GenCost {
1073 model: 1,
1074 startup: 0.0,
1075 shutdown: 0.0,
1076 ncost: 4,
1077 coeffs: vec![
1078 0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1079 ],
1080 };
1081 let out = cost_to_pu(&cost, 100.0);
1082 assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1083 assert!(
1084 approx(out[0], 0.0)
1085 && approx(out[2], 1.0)
1086 && approx(out[4], 2.0)
1087 && approx(out[6], 2.5)
1088 );
1089 assert!(
1090 approx(out[1], 0.0)
1091 && approx(out[3], 2500.0)
1092 && approx(out[5], 5500.0)
1093 && approx(out[7], 7250.0)
1094 );
1095 }
1096
1097 #[test]
1098 fn cost_rescale_round_trips() {
1099 let cost = GenCost {
1101 model: 2,
1102 startup: 0.0,
1103 shutdown: 0.0,
1104 ncost: 3,
1105 coeffs: vec![0.11, 5.0, 150.0],
1106 };
1107 let pu = cost_to_pu(&cost, 100.0);
1108 assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1110 assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1111 assert!((pu[2] - 150.0).abs() < 1e-9);
1112 let back = cost_from_pu(&pu, 2, 100.0);
1113 for (a, b) in back.iter().zip(&cost.coeffs) {
1114 assert!((a - b).abs() < 1e-9);
1115 }
1116 }
1117
1118 #[test]
1119 fn cost_rescale_passes_through_unknown_model() {
1120 let cost = GenCost {
1124 model: 0,
1125 startup: 0.0,
1126 shutdown: 0.0,
1127 ncost: 2,
1128 coeffs: vec![3.0, 7.0, 9.0],
1129 };
1130 let pu = cost_to_pu(&cost, 100.0);
1131 assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1132 let back = cost_from_pu(&pu, cost.model, 100.0);
1133 assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1134 }
1135
1136 #[test]
1137 fn cost_rescale_round_trips_piecewise() {
1138 let cost = GenCost {
1142 model: 1,
1143 startup: 0.0,
1144 shutdown: 0.0,
1145 ncost: 4,
1146 coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1147 };
1148 let pu = cost_to_pu(&cost, 100.0);
1149 let back = cost_from_pu(&pu, 1, 100.0);
1150 for (a, b) in back.iter().zip(&cost.coeffs) {
1151 assert!((a - b).abs() < 1e-9, "{a} != {b}");
1152 }
1153 }
1154}