1use std::collections::{HashMap, HashSet};
16
17use crate::network::{
18 BalancedNetwork, Branch, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load,
19 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)]
62pub struct NormalizedNetwork {
63 pub network: BalancedNetwork,
64 pub warnings: Vec<String>,
65}
66
67#[derive(Clone, Debug)]
89#[non_exhaustive]
90pub struct NormalizeSourceRows {
91 pub buses: Vec<Option<usize>>,
92 pub loads: Vec<Option<usize>>,
93 pub shunts: Vec<Option<usize>>,
94 pub branches: Vec<Option<usize>>,
95 pub switches: Vec<Option<usize>>,
96 pub generators: Vec<Option<usize>>,
97 pub storage: Vec<Option<usize>>,
98 pub hvdc: Vec<Option<usize>>,
99 pub transformers_3w: Vec<Option<usize>>,
100}
101
102impl NormalizeSourceRows {
103 pub(crate) fn identity(net: &BalancedNetwork) -> Self {
107 let ident = |n: usize| (0..n).map(Some).collect();
108 Self {
109 buses: ident(net.buses.len()),
110 loads: ident(net.loads.len()),
111 shunts: ident(net.shunts.len()),
112 branches: ident(net.branches.len()),
113 switches: ident(net.switches.len()),
114 generators: ident(net.generators.len()),
115 storage: ident(net.storage.len()),
116 hvdc: ident(net.hvdc.len()),
117 transformers_3w: ident(net.transformers_3w.len()),
118 }
119 }
120
121 pub(crate) fn pad_to_lowered(&mut self, net: &BalancedNetwork) {
126 let lengths = net.lowered_lengths();
127 self.buses.resize(lengths.buses, None);
128 self.branches.resize(lengths.branches, None);
129 self.shunts.resize(lengths.shunts, None);
130 }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134enum CostModel {
135 Piecewise,
136 Polynomial,
137 Unknown,
138}
139
140impl From<u8> for CostModel {
141 fn from(value: u8) -> Self {
142 match value {
143 1 => CostModel::Piecewise,
144 2 => CostModel::Polynomial,
145 _ => CostModel::Unknown,
146 }
147 }
148}
149
150pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
162 let mut coeffs = cost.coeffs.clone();
163 scale_coeffs_to_pu(&mut coeffs, cost.ncost, cost.model, base);
164 coeffs
165}
166
167pub(crate) fn scale_coeffs_to_pu(coeffs: &mut Vec<f64>, ncost: usize, model: u8, base: f64) {
170 match CostModel::from(model) {
171 CostModel::Polynomial => {
172 coeffs.truncate(ncost.min(coeffs.len()));
173 let k = coeffs.len();
174 for (i, c) in coeffs.iter_mut().enumerate() {
177 *c *= base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"));
178 }
179 }
180 CostModel::Piecewise => {
181 coeffs.truncate(ncost.saturating_mul(2).min(coeffs.len()));
185 for c in coeffs.iter_mut().step_by(2) {
186 *c /= base;
187 }
188 }
189 CostModel::Unknown => {}
190 }
191}
192
193pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
199 let k = coeffs.len();
200 match CostModel::from(model) {
201 CostModel::Polynomial => coeffs
202 .iter()
203 .enumerate()
204 .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
205 .collect(),
206 CostModel::Piecewise => coeffs
207 .iter()
208 .enumerate()
209 .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
210 .collect(),
211 CostModel::Unknown => coeffs.to_vec(),
212 }
213}
214
215fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
217 map.get(&id).copied()
218}
219
220fn norm_loads(
221 loads: &[Load],
222 base: f64,
223 map: &HashMap<BusId, BusId>,
224) -> (Vec<Load>, Vec<Option<usize>>) {
225 loads
226 .iter()
227 .enumerate()
228 .filter(|(_, l)| l.in_service)
229 .filter_map(|(row, l)| {
230 Some((
231 Load {
232 bus: remap(map, l.bus)?,
233 p: l.p / base,
234 q: l.q / base,
235 voltage_model: l
236 .voltage_model
237 .as_ref()
238 .map(|m| norm_load_voltage_model(m, base)),
239 ..l.clone()
240 },
241 Some(row),
242 ))
243 })
244 .unzip()
245}
246
247fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
248 match model {
249 LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
250 LoadVoltageModel::Zip {
251 p_constant_power,
252 q_constant_power,
253 p_constant_current,
254 q_constant_current,
255 p_constant_impedance,
256 q_constant_impedance,
257 v_nom,
258 load_type,
259 scaling,
260 } => LoadVoltageModel::Zip {
261 p_constant_power: p_constant_power / base,
262 q_constant_power: q_constant_power / base,
263 p_constant_current: p_constant_current / base,
264 q_constant_current: q_constant_current / base,
265 p_constant_impedance: p_constant_impedance / base,
266 q_constant_impedance: q_constant_impedance / base,
267 v_nom: *v_nom,
268 load_type: *load_type,
269 scaling: *scaling,
270 },
271 LoadVoltageModel::Exponential {
272 p,
273 q,
274 v_nom,
275 gamma_p,
276 gamma_q,
277 } => LoadVoltageModel::Exponential {
278 p: p / base,
279 q: q / base,
280 v_nom: *v_nom,
281 gamma_p: *gamma_p,
282 gamma_q: *gamma_q,
283 },
284 }
285}
286
287fn norm_shunts(
288 shunts: &[Shunt],
289 base: f64,
290 map: &HashMap<BusId, BusId>,
291) -> (Vec<Shunt>, Vec<Option<usize>>) {
292 shunts
293 .iter()
294 .enumerate()
295 .filter(|(_, s)| s.in_service)
296 .filter_map(|(row, s)| {
297 let mut shunt = s.clone();
298 shunt.bus = remap(map, s.bus)?;
299 shunt.g = s.g / base;
300 shunt.b = s.b / base;
301 if let Some(c) = &mut shunt.control {
304 c.control_bus = c.control_bus.and_then(|b| remap(map, b));
305 }
306 Some((shunt, Some(row)))
307 })
308 .unzip()
309}
310
311fn norm_branches(
312 branches: &[Branch],
313 base: f64,
314 map: &HashMap<BusId, BusId>,
315) -> (Vec<Branch>, Vec<Option<usize>>) {
316 branches
317 .iter()
318 .enumerate()
319 .filter(|(_, br)| br.in_service)
320 .filter_map(|(row, br)| {
321 let mut branch = br.clone();
322 branch.from = remap(map, br.from)?;
323 branch.to = remap(map, br.to)?;
324 branch.rate_a = br.rate_a / base;
325 branch.rate_b = br.rate_b / base;
326 branch.rate_c = br.rate_c / base;
327 for set in &mut branch.rating_sets {
328 set.rate_mva /= base;
329 }
330 branch.tap = br.effective_tap();
331 branch.shift = br.shift * DEG_TO_RAD;
332 branch.angmin = br.angmin * DEG_TO_RAD;
333 branch.angmax = br.angmax * DEG_TO_RAD;
334 if let Some(s) = &mut branch.solution {
335 s.pf /= base;
336 s.qf /= base;
337 s.pt /= base;
338 s.qt /= base;
339 }
340 if let Some(c) = &mut branch.control {
344 c.controlled_bus = c.controlled_bus.and_then(|b| remap(map, b));
345 }
346 Some((branch, Some(row)))
347 })
348 .unzip()
349}
350
351fn validate_normalize_options(options: &NormalizeOptions) -> Result<()> {
352 if options.clamp_angle_bounds
353 && (!options.angle_bound_pad.is_finite()
354 || options.angle_bound_pad <= 0.0
355 || options.angle_bound_pad >= std::f64::consts::FRAC_PI_2)
356 {
357 return Err(Error::InvalidNormalizeOption {
358 field: "angle_bound_pad",
359 value: options.angle_bound_pad,
360 });
361 }
362 Ok(())
363}
364
365fn clamp_angle_bounds(branches: &mut [Branch], pad: f64, warnings: &mut Vec<String>) {
366 for (idx, br) in branches.iter_mut().enumerate() {
367 let old_min = br.angmin;
368 let old_max = br.angmax;
369 let mut changes = Vec::new();
370
371 if old_min <= -std::f64::consts::FRAC_PI_2 {
372 br.angmin = -pad;
373 changes.push(format!("angmin {old_min} -> {}", br.angmin));
374 }
375 if old_max >= std::f64::consts::FRAC_PI_2 {
376 br.angmax = pad;
377 changes.push(format!("angmax {old_max} -> {}", br.angmax));
378 }
379 if old_min == 0.0 && old_max == 0.0 {
380 br.angmin = -pad;
381 br.angmax = pad;
382 changes.push(format!("angmin/angmax 0 -> [{}, {}]", br.angmin, br.angmax));
383 }
384 if !changes.is_empty() && br.angmin > br.angmax {
385 let repaired_min = br.angmin;
386 let repaired_max = br.angmax;
387 br.angmin = -pad;
388 br.angmax = pad;
389 changes.push(format!(
390 "repaired interval {repaired_min}..{repaired_max} widened to [{}, {}]",
391 br.angmin, br.angmax
392 ));
393 }
394
395 if !changes.is_empty() {
396 warnings.push(format!(
397 "branch {idx} angle difference bounds clamped: {}",
398 changes.join(", ")
399 ));
400 }
401 }
402}
403
404fn norm_gens(
405 gens: &[Generator],
406 base: f64,
407 map: &HashMap<BusId, BusId>,
408) -> (Vec<Generator>, Vec<Option<usize>>) {
409 gens.iter()
410 .enumerate()
411 .filter(|(_, g)| g.in_service)
412 .filter_map(|(row, g)| {
413 let mut generator = g.clone();
414 generator.bus = remap(map, g.bus)?;
415 generator.pg = g.pg / base;
416 generator.qg = g.qg / base;
417 generator.pmax = g.pmax / base;
418 generator.pmin = g.pmin / base;
419 generator.qmax = g.qmax / base;
420 generator.qmin = g.qmin / base;
421 if let Some(c) = &mut generator.cost {
422 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
423 }
424 for (cap, key) in generator.caps.iter_mut().zip(GEN_EXTRA_KEYS) {
426 if GEN_PU_KEYS.contains(&key)
427 && let Some(v) = cap
428 {
429 *v /= base;
430 }
431 }
432 generator.regulated_bus = g.regulated_bus.and_then(|b| remap(map, b));
435 Some((generator, Some(row)))
436 })
437 .unzip()
438}
439
440fn norm_switches(
441 switches: &[Switch],
442 base: f64,
443 map: &HashMap<BusId, BusId>,
444) -> (Vec<Switch>, Vec<Option<usize>>) {
445 switches
446 .iter()
447 .enumerate()
448 .filter_map(|(row, s)| {
449 let switch = Switch {
450 from: remap(map, s.from)?,
451 to: remap(map, s.to)?,
452 thermal_rating: s.thermal_rating.map(|v| v / base),
453 pf: s.pf.map(|v| v / base),
454 qf: s.qf.map(|v| v / base),
455 pt: s.pt.map(|v| v / base),
456 qt: s.qt.map(|v| v / base),
457 ..s.clone()
458 };
459 Some((switch, Some(row)))
460 })
461 .unzip()
462}
463
464fn norm_storage(
465 storage: &[Storage],
466 base: f64,
467 map: &HashMap<BusId, BusId>,
468) -> (Vec<Storage>, Vec<Option<usize>>) {
469 storage
470 .iter()
471 .enumerate()
472 .filter(|(_, s)| s.in_service)
473 .filter_map(|(row, s)| {
474 let unit = Storage {
477 bus: remap(map, s.bus)?,
478 energy: s.energy / base,
479 energy_rating: s.energy_rating / base,
480 charge_rating: s.charge_rating / base,
481 discharge_rating: s.discharge_rating / base,
482 thermal_rating: s.thermal_rating / base,
483 qmin: s.qmin / base,
484 qmax: s.qmax / base,
485 p_loss: s.p_loss / base,
486 q_loss: s.q_loss / base,
487 ..s.clone()
488 };
489 Some((unit, Some(row)))
490 })
491 .unzip()
492}
493
494fn norm_hvdc(
495 hvdc: &[Hvdc],
496 base: f64,
497 map: &HashMap<BusId, BusId>,
498) -> (Vec<Hvdc>, Vec<Option<usize>>) {
499 hvdc.iter()
500 .enumerate()
501 .filter(|(_, d)| d.in_service)
502 .filter_map(|(row, d)| {
503 let mut link = d.clone();
507 link.from = remap(map, d.from)?;
508 link.to = remap(map, d.to)?;
509 link.pf = d.pf / base;
510 link.pt = d.pt / base;
511 link.qf = d.qf / base;
512 link.qt = d.qt / base;
513 link.qminf = d.qminf / base;
514 link.qmaxf = d.qmaxf / base;
515 link.qmint = d.qmint / base;
516 link.qmaxt = d.qmaxt / base;
517 link.loss0 = d.loss0 / base;
518 if let Some(c) = &mut link.cost {
519 scale_coeffs_to_pu(&mut c.coeffs, c.ncost, c.model, base);
520 }
521 Some((link, Some(row)))
522 })
523 .unzip()
524}
525
526fn norm_transformers_3w(
527 xfmrs: &[Transformer3W],
528 base: f64,
529 map: &HashMap<BusId, BusId>,
530) -> (Vec<Transformer3W>, Vec<Option<usize>>) {
531 xfmrs
532 .iter()
533 .enumerate()
534 .filter(|(_, t)| t.in_service)
535 .filter_map(|(row, t)| {
536 let mut windings = t.windings.clone();
541 for w in &mut windings {
542 w.bus = remap(map, w.bus)?;
543 w.shift *= DEG_TO_RAD;
544 w.rate_a /= base;
545 w.rate_b /= base;
546 w.rate_c /= base;
547 }
548 Some((
549 Transformer3W {
550 windings,
551 star_va: t.star_va * DEG_TO_RAD,
552 ..t.clone()
553 },
554 Some(row),
555 ))
556 })
557 .unzip()
558}
559
560impl BalancedNetwork {
561 pub fn to_normalized(&self) -> Result<BalancedNetwork> {
609 Ok(self
610 .to_normalized_with_options(&NormalizeOptions::default())?
611 .network)
612 }
613
614 pub fn to_normalized_with_options(
617 &self,
618 options: &NormalizeOptions,
619 ) -> Result<NormalizedNetwork> {
620 Ok(self.normalize_inner(options)?.0)
621 }
622
623 pub fn to_normalized_with_source_rows(
648 &self,
649 options: &NormalizeOptions,
650 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
651 let (normalized, mut rows) = self.normalize_inner(options)?;
652 rows.pad_to_lowered(&normalized.network);
653 Ok((normalized, rows))
654 }
655
656 fn normalize_inner(
660 &self,
661 options: &NormalizeOptions,
662 ) -> Result<(NormalizedNetwork, NormalizeSourceRows)> {
663 validate_normalize_options(options)?;
664 self.check_base_mva()?;
665 let base = self.base_mva;
666
667 let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses.len());
670 let mut buses: Vec<Bus> = Vec::with_capacity(self.buses.len());
671 let mut bus_rows: Vec<Option<usize>> = Vec::with_capacity(self.buses.len());
675 for (row, b) in self.buses.iter().enumerate() {
676 if b.kind == BusType::Isolated {
677 continue;
678 }
679 id_map.insert(b.id, b.id);
680 buses.push(Bus {
681 va: b.va * DEG_TO_RAD,
682 ..b.clone()
683 });
684 bus_rows.push(Some(row));
685 }
686 let (loads, load_rows) = norm_loads(&self.loads, base, &id_map);
687 let (shunts, shunt_rows) = norm_shunts(&self.shunts, base, &id_map);
688 let (mut branches, branch_rows) = norm_branches(&self.branches, base, &id_map);
689 let mut warnings = Vec::new();
690 if options.clamp_angle_bounds {
691 clamp_angle_bounds(&mut branches, options.angle_bound_pad, &mut warnings);
692 }
693 let (switches, switch_rows) = norm_switches(&self.switches, base, &id_map);
694 let (generators, generator_rows) = norm_gens(&self.generators, base, &id_map);
695 let (storage, storage_rows) = norm_storage(&self.storage, base, &id_map);
696 let (hvdc, hvdc_rows) = norm_hvdc(&self.hvdc, base, &id_map);
697 let (transformers_3w, transformer_3w_rows) =
698 norm_transformers_3w(&self.transformers_3w, base, &id_map);
699 let source_rows = NormalizeSourceRows {
700 buses: bus_rows,
701 loads: load_rows,
702 shunts: shunt_rows,
703 branches: branch_rows,
704 switches: switch_rows,
705 generators: generator_rows,
706 storage: storage_rows,
707 hvdc: hvdc_rows,
708 transformers_3w: transformer_3w_rows,
709 };
710
711 let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
716 for b in &mut buses {
717 b.kind = match (gen_buses.contains(&b.id), b.kind) {
718 (true, BusType::Ref) => BusType::Ref,
719 (true, _) => BusType::Pv,
720 (false, _) => BusType::Pq,
721 };
722 }
723 if !buses.iter().any(|b| b.kind == BusType::Ref) {
724 let slack = generators
727 .iter()
728 .max_by(|a, b| {
729 let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
733 key(a.pmax).total_cmp(&key(b.pmax))
734 })
735 .map(|g| g.bus)
736 .ok_or(Error::ReferenceBusCount { found: 0 })?;
737 if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
738 b.kind = BusType::Ref;
739 }
740 }
741
742 let net = BalancedNetwork {
743 name: self.name.clone(),
744 base_mva: base,
745 base_frequency: self.base_frequency,
746 geo: self.geo.clone(),
747 buses,
748 loads,
749 shunts,
750 branches,
751 switches,
752 generators,
753 storage,
754 hvdc,
755 transformers_3w,
756 areas: Vec::new(),
759 solver: None,
760 source_format: SourceFormat::Normalized,
761 source: None,
762 };
763 debug_assert!(
767 net.validate().is_ok(),
768 "to_normalized produced a dangling reference"
769 );
770 Ok((
771 NormalizedNetwork {
772 network: net,
773 warnings,
774 },
775 source_rows,
776 ))
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 fn approx(a: f64, b: f64) -> bool {
785 (a - b).abs() < 1e-9
786 }
787
788 fn angle_bound_fixture() -> BalancedNetwork {
789 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
790 .join("../tests/data/angle_bounds_clamp.m");
791 crate::parse_file(path, None).unwrap().network
792 }
793
794 #[test]
795 fn angle_bound_clamp_is_opt_in_and_matches_powermodels_rules() {
796 let net = angle_bound_fixture();
797
798 let plain = net.to_normalized().unwrap();
799 assert!(approx(plain.branches[0].angmin, -std::f64::consts::TAU));
800 assert!(approx(plain.branches[0].angmax, std::f64::consts::TAU));
801 assert!(approx(plain.branches[1].angmin, 0.0));
802 assert!(approx(plain.branches[1].angmax, 0.0));
803 assert!(approx(plain.branches[3].angmin, -120.0 * DEG_TO_RAD));
804 assert!(approx(plain.branches[3].angmax, -100.0 * DEG_TO_RAD));
805 assert!(approx(plain.branches[4].angmin, 100.0 * DEG_TO_RAD));
806 assert!(approx(plain.branches[4].angmax, 120.0 * DEG_TO_RAD));
807
808 let out = net
809 .to_normalized_with_options(&NormalizeOptions {
810 clamp_angle_bounds: true,
811 ..NormalizeOptions::default()
812 })
813 .unwrap();
814 assert_eq!(out.warnings.len(), 4);
815 assert!(out.warnings[0].contains("branch 0"));
816 assert!(out.warnings[1].contains("branch 1"));
817 assert!(out.warnings[2].contains("branch 3"));
818 assert!(out.warnings[3].contains("branch 4"));
819
820 let branches = &out.network.branches;
821 assert!(approx(branches[0].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
822 assert!(approx(branches[0].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
823 assert!(approx(branches[1].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
824 assert!(approx(branches[1].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
825 assert!(approx(branches[2].angmin, -30.0 * DEG_TO_RAD));
826 assert!(approx(branches[2].angmax, 30.0 * DEG_TO_RAD));
827 assert!(approx(branches[3].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
828 assert!(approx(branches[3].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
829 assert!(approx(branches[4].angmin, -POWER_MODELS_ANGLE_BOUND_PAD));
830 assert!(approx(branches[4].angmax, POWER_MODELS_ANGLE_BOUND_PAD));
831 assert!(branches.iter().all(|br| br.angmin <= br.angmax));
832 }
833
834 #[test]
835 fn angle_bound_clamp_rejects_invalid_pad() {
836 let net = angle_bound_fixture();
837 let err = net
838 .to_normalized_with_options(&NormalizeOptions {
839 clamp_angle_bounds: true,
840 angle_bound_pad: std::f64::consts::FRAC_PI_2,
841 })
842 .unwrap_err();
843 assert!(matches!(
844 err,
845 Error::InvalidNormalizeOption {
846 field: "angle_bound_pad",
847 ..
848 }
849 ));
850 }
851
852 #[test]
853 fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
854 use crate::network::{Extras, SwitchedShuntControl, SwitchedShuntMode};
855
856 let mkbus = |id: usize, kind: BusType| Bus {
857 id: BusId(id),
858 kind,
859 vm: 1.0,
860 va: 0.0,
861 base_kv: 230.0,
862 vmax: 1.1,
863 vmin: 0.9,
864 evhi: None,
865 evlo: None,
866 area: 1,
867 zone: 1,
868 name: None,
869 uid: None,
870 location: None,
871 extras: Extras::new(),
872 };
873 let branch = Branch {
874 from: BusId(1),
875 to: BusId(2),
876 r: 0.0,
877 x: 0.1,
878 b: 0.0,
879 charging: None,
880 rate_a: 0.0,
881 rate_b: 0.0,
882 rate_c: 0.0,
883 rating_sets: Vec::new(),
884 current_ratings: None,
885 tap: 0.0,
886 shift: 0.0,
887 in_service: true,
888 angmin: -360.0,
889 angmax: 360.0,
890 control: None,
891 solution: None,
892 uid: None,
893 route: None,
894 extras: Extras::new(),
895 };
896 let mut net = BalancedNetwork::in_memory(
898 "n",
899 100.0,
900 vec![
901 mkbus(1, BusType::Ref),
902 mkbus(2, BusType::Pq),
903 mkbus(3, BusType::Isolated),
904 ],
905 vec![branch],
906 );
907 net.generators.push(Generator {
908 bus: BusId(1),
909 pg: 10.0,
910 qg: 0.0,
911 pmax: 100.0,
912 pmin: 0.0,
913 qmax: 50.0,
914 qmin: -50.0,
915 vg: 1.0,
916 mbase: 100.0,
917 in_service: true,
918 cost: None,
919 caps: Default::default(),
920 regulated_bus: None,
921 uid: None,
922 });
923 net.shunts.push(Shunt {
925 bus: BusId(2),
926 g: 0.0,
927 b: 10.0,
928 in_service: true,
929 control: Some(SwitchedShuntControl {
930 mode: SwitchedShuntMode::Discrete,
931 vhigh: 1.05,
932 vlow: 0.95,
933 control_bus: Some(BusId(3)),
934 rmpct: 100.0,
935 blocks: Vec::new(),
936 }),
937 uid: None,
938 extras: Extras::new(),
939 });
940
941 let norm = net.to_normalized().unwrap();
942 norm.validate().unwrap();
943 let c = norm.shunts[0].control.as_ref().expect("control retained");
944 assert_eq!(
945 c.control_bus, None,
946 "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
947 );
948 }
949
950 #[test]
951 fn normalized_slack_tiebreak_ignores_nan_pmax() {
952 use crate::network::Extras;
953
954 let mkbus = |id: usize| Bus {
955 id: BusId(id),
956 kind: BusType::Pq,
957 vm: 1.0,
958 va: 0.0,
959 base_kv: 230.0,
960 vmax: 1.1,
961 vmin: 0.9,
962 evhi: None,
963 evlo: None,
964 area: 1,
965 zone: 1,
966 name: None,
967 uid: None,
968 location: None,
969 extras: Extras::new(),
970 };
971 let mkgen = |bus: usize, pmax: f64| Generator {
972 bus: BusId(bus),
973 pg: 0.0,
974 qg: 0.0,
975 pmax,
976 pmin: 0.0,
977 qmax: 0.0,
978 qmin: 0.0,
979 vg: 1.0,
980 mbase: 100.0,
981 in_service: true,
982 cost: None,
983 caps: Default::default(),
984 regulated_bus: None,
985 uid: None,
986 };
987 let mut net = BalancedNetwork::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
988 net.generators = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
989
990 let norm = net.to_normalized().unwrap();
991
992 assert_eq!(
993 norm.buses.iter().find(|b| b.id == BusId(1)).unwrap().kind,
994 BusType::Pv
995 );
996 assert_eq!(
997 norm.buses.iter().find(|b| b.id == BusId(2)).unwrap().kind,
998 BusType::Ref
999 );
1000 }
1001
1002 #[test]
1003 fn cost_to_pu_polynomial_scales_and_trims() {
1004 let cost = GenCost {
1007 model: 2,
1008 startup: 0.0,
1009 shutdown: 0.0,
1010 ncost: 2,
1011 coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
1012 };
1013 let out = cost_to_pu(&cost, 100.0);
1014 assert_eq!(out.len(), 2, "padding dropped");
1015 assert!(approx(out[0], 2403.5)); assert!(approx(out[1], -403.5)); }
1018
1019 #[test]
1020 fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
1021 let cost = GenCost {
1023 model: 1,
1024 startup: 0.0,
1025 shutdown: 0.0,
1026 ncost: 4,
1027 coeffs: vec![
1028 0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
1029 ],
1030 };
1031 let out = cost_to_pu(&cost, 100.0);
1032 assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
1033 assert!(
1034 approx(out[0], 0.0)
1035 && approx(out[2], 1.0)
1036 && approx(out[4], 2.0)
1037 && approx(out[6], 2.5)
1038 );
1039 assert!(
1040 approx(out[1], 0.0)
1041 && approx(out[3], 2500.0)
1042 && approx(out[5], 5500.0)
1043 && approx(out[7], 7250.0)
1044 );
1045 }
1046
1047 #[test]
1048 fn cost_rescale_round_trips() {
1049 let cost = GenCost {
1051 model: 2,
1052 startup: 0.0,
1053 shutdown: 0.0,
1054 ncost: 3,
1055 coeffs: vec![0.11, 5.0, 150.0],
1056 };
1057 let pu = cost_to_pu(&cost, 100.0);
1058 assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
1060 assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
1061 assert!((pu[2] - 150.0).abs() < 1e-9);
1062 let back = cost_from_pu(&pu, 2, 100.0);
1063 for (a, b) in back.iter().zip(&cost.coeffs) {
1064 assert!((a - b).abs() < 1e-9);
1065 }
1066 }
1067
1068 #[test]
1069 fn cost_rescale_passes_through_unknown_model() {
1070 let cost = GenCost {
1074 model: 0,
1075 startup: 0.0,
1076 shutdown: 0.0,
1077 ncost: 2,
1078 coeffs: vec![3.0, 7.0, 9.0],
1079 };
1080 let pu = cost_to_pu(&cost, 100.0);
1081 assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
1082 let back = cost_from_pu(&pu, cost.model, 100.0);
1083 assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
1084 }
1085
1086 #[test]
1087 fn cost_rescale_round_trips_piecewise() {
1088 let cost = GenCost {
1092 model: 1,
1093 startup: 0.0,
1094 shutdown: 0.0,
1095 ncost: 4,
1096 coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
1097 };
1098 let pu = cost_to_pu(&cost, 100.0);
1099 let back = cost_from_pu(&pu, 1, 100.0);
1100 for (a, b) in back.iter().zip(&cost.coeffs) {
1101 assert!((a - b).abs() < 1e-9, "{a} != {b}");
1102 }
1103 }
1104}