1use std::collections::{HashMap, HashSet};
16
17use crate::network::{
18 Branch, BranchRatingSet, Bus, BusId, BusType, GEN_EXTRA_KEYS, GenCost, Generator, Hvdc, Load,
19 LoadVoltageModel, Network, 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38enum CostModel {
39 Piecewise,
40 Polynomial,
41 Unknown,
42}
43
44impl From<u8> for CostModel {
45 fn from(value: u8) -> Self {
46 match value {
47 1 => CostModel::Piecewise,
48 2 => CostModel::Polynomial,
49 _ => CostModel::Unknown,
50 }
51 }
52}
53
54pub(crate) fn cost_to_pu(cost: &GenCost, base: f64) -> Vec<f64> {
66 match CostModel::from(cost.model) {
67 CostModel::Polynomial => {
68 let coeffs = &cost.coeffs[..cost.ncost.min(cost.coeffs.len())];
69 let k = coeffs.len();
70 coeffs
73 .iter()
74 .enumerate()
75 .map(|(i, &c)| {
76 c * base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32"))
77 })
78 .collect()
79 }
80 CostModel::Piecewise => {
81 let coeffs = &cost.coeffs[..(cost.ncost * 2).min(cost.coeffs.len())];
82 coeffs
83 .iter()
84 .enumerate()
85 .map(|(i, &c)| if i % 2 == 0 { c / base } else { c })
86 .collect()
87 }
88 CostModel::Unknown => cost.coeffs.clone(),
89 }
90}
91
92pub(crate) fn cost_from_pu(coeffs: &[f64], model: u8, base: f64) -> Vec<f64> {
98 let k = coeffs.len();
99 match CostModel::from(model) {
100 CostModel::Polynomial => coeffs
101 .iter()
102 .enumerate()
103 .map(|(i, &c)| c / base.powi(i32::try_from(k - 1 - i).expect("cost degree fits i32")))
104 .collect(),
105 CostModel::Piecewise => coeffs
106 .iter()
107 .enumerate()
108 .map(|(i, &c)| if i % 2 == 0 { c * base } else { c })
109 .collect(),
110 CostModel::Unknown => coeffs.to_vec(),
111 }
112}
113
114fn remap(map: &HashMap<BusId, BusId>, id: BusId) -> Option<BusId> {
116 map.get(&id).copied()
117}
118
119fn norm_loads(loads: &[Load], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Load> {
120 loads
121 .iter()
122 .filter(|l| l.in_service)
123 .filter_map(|l| {
124 Some(Load {
125 bus: remap(map, l.bus)?,
126 p: l.p / base,
127 q: l.q / base,
128 voltage_model: l
129 .voltage_model
130 .as_ref()
131 .map(|m| norm_load_voltage_model(m, base)),
132 ..l.clone()
133 })
134 })
135 .collect()
136}
137
138fn norm_load_voltage_model(model: &LoadVoltageModel, base: f64) -> LoadVoltageModel {
139 match model {
140 LoadVoltageModel::ConstantPower => LoadVoltageModel::ConstantPower,
141 LoadVoltageModel::Zip {
142 p_constant_power,
143 q_constant_power,
144 p_constant_current,
145 q_constant_current,
146 p_constant_impedance,
147 q_constant_impedance,
148 v_nom,
149 load_type,
150 scaling,
151 } => LoadVoltageModel::Zip {
152 p_constant_power: p_constant_power / base,
153 q_constant_power: q_constant_power / base,
154 p_constant_current: p_constant_current / base,
155 q_constant_current: q_constant_current / base,
156 p_constant_impedance: p_constant_impedance / base,
157 q_constant_impedance: q_constant_impedance / base,
158 v_nom: *v_nom,
159 load_type: *load_type,
160 scaling: *scaling,
161 },
162 LoadVoltageModel::Exponential {
163 p,
164 q,
165 v_nom,
166 gamma_p,
167 gamma_q,
168 } => LoadVoltageModel::Exponential {
169 p: p / base,
170 q: q / base,
171 v_nom: *v_nom,
172 gamma_p: *gamma_p,
173 gamma_q: *gamma_q,
174 },
175 }
176}
177
178fn norm_shunts(shunts: &[Shunt], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Shunt> {
179 shunts
180 .iter()
181 .filter(|s| s.in_service)
182 .filter_map(|s| {
183 Some(Shunt {
184 bus: remap(map, s.bus)?,
185 g: s.g / base,
186 b: s.b / base,
187 control: s.control.clone().map(|mut c| {
190 c.control_bus = c.control_bus.and_then(|b| remap(map, b));
191 c
192 }),
193 ..s.clone()
194 })
195 })
196 .collect()
197}
198
199fn norm_branches(branches: &[Branch], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Branch> {
200 branches
201 .iter()
202 .filter(|br| br.in_service)
203 .filter_map(|br| {
204 Some(Branch {
205 from: remap(map, br.from)?,
206 to: remap(map, br.to)?,
207 rate_a: br.rate_a / base,
208 rate_b: br.rate_b / base,
209 rate_c: br.rate_c / base,
210 rating_sets: br
211 .rating_sets
212 .iter()
213 .map(|r| BranchRatingSet {
214 name: r.name.clone(),
215 rate_mva: r.rate_mva / base,
216 })
217 .collect(),
218 tap: br.effective_tap(),
219 shift: br.shift * DEG_TO_RAD,
220 angmin: br.angmin * DEG_TO_RAD,
221 angmax: br.angmax * DEG_TO_RAD,
222 solution: br.solution.map(|s| crate::network::BranchSolution {
223 pf: s.pf / base,
224 qf: s.qf / base,
225 pt: s.pt / base,
226 qt: s.qt / base,
227 }),
228 control: br.control.clone().map(|mut c| {
232 c.controlled_bus = c.controlled_bus.and_then(|b| remap(map, b));
233 c
234 }),
235 ..br.clone()
236 })
237 })
238 .collect()
239}
240
241fn norm_gens(gens: &[Generator], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Generator> {
242 gens.iter()
243 .filter(|g| g.in_service)
244 .filter_map(|g| {
245 let bus = remap(map, g.bus)?;
246 let mut caps = g.caps;
247 for (i, key) in GEN_EXTRA_KEYS.iter().enumerate() {
248 if GEN_PU_KEYS.contains(key) {
249 if let Some(v) = caps[i] {
250 caps[i] = Some(v / base);
251 }
252 }
253 }
254 Some(Generator {
255 bus,
256 pg: g.pg / base,
257 qg: g.qg / base,
258 pmax: g.pmax / base,
259 pmin: g.pmin / base,
260 qmax: g.qmax / base,
261 qmin: g.qmin / base,
262 cost: g.cost.as_ref().map(|c| GenCost {
263 coeffs: cost_to_pu(c, base),
264 ..c.clone()
265 }),
266 caps,
267 regulated_bus: g.regulated_bus.and_then(|b| remap(map, b)),
270 ..g.clone()
271 })
272 })
273 .collect()
274}
275
276fn norm_switches(switches: &[Switch], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Switch> {
277 switches
278 .iter()
279 .filter_map(|s| {
280 Some(Switch {
281 from: remap(map, s.from)?,
282 to: remap(map, s.to)?,
283 thermal_rating: s.thermal_rating.map(|v| v / base),
284 pf: s.pf.map(|v| v / base),
285 qf: s.qf.map(|v| v / base),
286 pt: s.pt.map(|v| v / base),
287 qt: s.qt.map(|v| v / base),
288 ..s.clone()
289 })
290 })
291 .collect()
292}
293
294fn norm_storage(storage: &[Storage], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Storage> {
295 storage
296 .iter()
297 .filter(|s| s.in_service)
298 .filter_map(|s| {
299 Some(Storage {
302 bus: remap(map, s.bus)?,
303 energy: s.energy / base,
304 energy_rating: s.energy_rating / base,
305 charge_rating: s.charge_rating / base,
306 discharge_rating: s.discharge_rating / base,
307 thermal_rating: s.thermal_rating / base,
308 qmin: s.qmin / base,
309 qmax: s.qmax / base,
310 p_loss: s.p_loss / base,
311 q_loss: s.q_loss / base,
312 ..s.clone()
313 })
314 })
315 .collect()
316}
317
318fn norm_hvdc(hvdc: &[Hvdc], base: f64, map: &HashMap<BusId, BusId>) -> Vec<Hvdc> {
319 hvdc.iter()
320 .filter(|d| d.in_service)
321 .filter_map(|d| {
322 Some(Hvdc {
326 from: remap(map, d.from)?,
327 to: remap(map, d.to)?,
328 pf: d.pf / base,
329 pt: d.pt / base,
330 qf: d.qf / base,
331 qt: d.qt / base,
332 qminf: d.qminf / base,
333 qmaxf: d.qmaxf / base,
334 qmint: d.qmint / base,
335 qmaxt: d.qmaxt / base,
336 loss0: d.loss0 / base,
337 cost: d.cost.as_ref().map(|c| GenCost {
338 coeffs: cost_to_pu(c, base),
339 ..c.clone()
340 }),
341 ..d.clone()
342 })
343 })
344 .collect()
345}
346
347fn norm_transformers_3w(
348 xfmrs: &[Transformer3W],
349 base: f64,
350 map: &HashMap<BusId, BusId>,
351) -> Vec<Transformer3W> {
352 xfmrs
353 .iter()
354 .filter(|t| t.in_service)
355 .filter_map(|t| {
356 let mut windings = t.windings.clone();
361 for w in &mut windings {
362 w.bus = remap(map, w.bus)?;
363 w.shift *= DEG_TO_RAD;
364 w.rate_a /= base;
365 w.rate_b /= base;
366 w.rate_c /= base;
367 }
368 Some(Transformer3W {
369 windings,
370 star_va: t.star_va * DEG_TO_RAD,
371 ..t.clone()
372 })
373 })
374 .collect()
375}
376
377impl Network {
378 pub fn to_normalized(&self) -> Result<Network> {
424 self.check_base_mva()?;
425 let base = self.base_mva;
426
427 let mut id_map: HashMap<BusId, BusId> = HashMap::with_capacity(self.buses.len());
430 let mut buses: Vec<Bus> = Vec::with_capacity(self.buses.len());
431 for b in &self.buses {
432 if b.kind == BusType::Isolated {
433 continue;
434 }
435 id_map.insert(b.id, b.id);
436 buses.push(Bus {
437 va: b.va * DEG_TO_RAD,
438 ..b.clone()
439 });
440 }
441 let loads = norm_loads(&self.loads, base, &id_map);
442 let shunts = norm_shunts(&self.shunts, base, &id_map);
443 let branches = norm_branches(&self.branches, base, &id_map);
444 let switches = norm_switches(&self.switches, base, &id_map);
445 let generators = norm_gens(&self.generators, base, &id_map);
446 let storage = norm_storage(&self.storage, base, &id_map);
447 let hvdc = norm_hvdc(&self.hvdc, base, &id_map);
448 let transformers_3w = norm_transformers_3w(&self.transformers_3w, base, &id_map);
449
450 let gen_buses: HashSet<BusId> = generators.iter().map(|g| g.bus).collect();
455 for b in &mut buses {
456 b.kind = match (gen_buses.contains(&b.id), b.kind) {
457 (true, BusType::Ref) => BusType::Ref,
458 (true, _) => BusType::Pv,
459 (false, _) => BusType::Pq,
460 };
461 }
462 if !buses.iter().any(|b| b.kind == BusType::Ref) {
463 let slack = generators
466 .iter()
467 .max_by(|a, b| {
468 let key = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p };
472 key(a.pmax).total_cmp(&key(b.pmax))
473 })
474 .map(|g| g.bus)
475 .ok_or(Error::ReferenceBusCount { found: 0 })?;
476 if let Some(b) = buses.iter_mut().find(|b| b.id == slack) {
477 b.kind = BusType::Ref;
478 }
479 }
480
481 let net = Network {
482 name: self.name.clone(),
483 base_mva: base,
484 base_frequency: self.base_frequency,
485 buses,
486 loads,
487 shunts,
488 branches,
489 switches,
490 generators,
491 storage,
492 hvdc,
493 transformers_3w,
494 areas: Vec::new(),
497 solver: None,
498 source_format: SourceFormat::Normalized,
499 source: None,
500 };
501 debug_assert!(
505 net.validate().is_ok(),
506 "to_normalized produced a dangling reference"
507 );
508 Ok(net)
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn approx(a: f64, b: f64) -> bool {
517 (a - b).abs() < 1e-9
518 }
519
520 #[test]
521 fn to_normalized_drops_a_control_bus_whose_target_was_filtered_out() {
522 use crate::network::{Extras, SwitchedShuntControl, SwitchedShuntMode};
523
524 let mkbus = |id: usize, kind: BusType| Bus {
525 id: BusId(id),
526 kind,
527 vm: 1.0,
528 va: 0.0,
529 base_kv: 230.0,
530 vmax: 1.1,
531 vmin: 0.9,
532 evhi: None,
533 evlo: None,
534 area: 1,
535 zone: 1,
536 name: None,
537 uid: None,
538 extras: Extras::new(),
539 };
540 let branch = Branch {
541 from: BusId(1),
542 to: BusId(2),
543 r: 0.0,
544 x: 0.1,
545 b: 0.0,
546 charging: None,
547 rate_a: 0.0,
548 rate_b: 0.0,
549 rate_c: 0.0,
550 rating_sets: Vec::new(),
551 current_ratings: None,
552 tap: 0.0,
553 shift: 0.0,
554 in_service: true,
555 angmin: -360.0,
556 angmax: 360.0,
557 control: None,
558 solution: None,
559 uid: None,
560 extras: Extras::new(),
561 };
562 let mut net = Network::in_memory(
564 "n",
565 100.0,
566 vec![
567 mkbus(1, BusType::Ref),
568 mkbus(2, BusType::Pq),
569 mkbus(3, BusType::Isolated),
570 ],
571 vec![branch],
572 );
573 net.generators.push(Generator {
574 bus: BusId(1),
575 pg: 10.0,
576 qg: 0.0,
577 pmax: 100.0,
578 pmin: 0.0,
579 qmax: 50.0,
580 qmin: -50.0,
581 vg: 1.0,
582 mbase: 100.0,
583 in_service: true,
584 cost: None,
585 caps: Default::default(),
586 regulated_bus: None,
587 uid: None,
588 });
589 net.shunts.push(Shunt {
591 bus: BusId(2),
592 g: 0.0,
593 b: 10.0,
594 in_service: true,
595 control: Some(SwitchedShuntControl {
596 mode: SwitchedShuntMode::Discrete,
597 vhigh: 1.05,
598 vlow: 0.95,
599 control_bus: Some(BusId(3)),
600 rmpct: 100.0,
601 blocks: Vec::new(),
602 }),
603 uid: None,
604 extras: Extras::new(),
605 });
606
607 let norm = net.to_normalized().unwrap();
608 norm.validate().unwrap();
609 let c = norm.shunts[0].control.as_ref().expect("control retained");
610 assert_eq!(
611 c.control_bus, None,
612 "a control bus pointing at a filtered-out isolated bus is dropped, not left dangling"
613 );
614 }
615
616 #[test]
617 fn normalized_slack_tiebreak_ignores_nan_pmax() {
618 use crate::network::Extras;
619
620 let mkbus = |id: usize| Bus {
621 id: BusId(id),
622 kind: BusType::Pq,
623 vm: 1.0,
624 va: 0.0,
625 base_kv: 230.0,
626 vmax: 1.1,
627 vmin: 0.9,
628 evhi: None,
629 evlo: None,
630 area: 1,
631 zone: 1,
632 name: None,
633 uid: None,
634 extras: Extras::new(),
635 };
636 let mkgen = |bus: usize, pmax: f64| Generator {
637 bus: BusId(bus),
638 pg: 0.0,
639 qg: 0.0,
640 pmax,
641 pmin: 0.0,
642 qmax: 0.0,
643 qmin: 0.0,
644 vg: 1.0,
645 mbase: 100.0,
646 in_service: true,
647 cost: None,
648 caps: Default::default(),
649 regulated_bus: None,
650 uid: None,
651 };
652 let mut net = Network::in_memory("n", 100.0, vec![mkbus(1), mkbus(2)], Vec::new());
653 net.generators = vec![mkgen(1, f64::NAN), mkgen(2, 10.0)];
654
655 let norm = net.to_normalized().unwrap();
656
657 assert_eq!(
658 norm.buses.iter().find(|b| b.id == BusId(1)).unwrap().kind,
659 BusType::Pv
660 );
661 assert_eq!(
662 norm.buses.iter().find(|b| b.id == BusId(2)).unwrap().kind,
663 BusType::Ref
664 );
665 }
666
667 #[test]
668 fn cost_to_pu_polynomial_scales_and_trims() {
669 let cost = GenCost {
672 model: 2,
673 startup: 0.0,
674 shutdown: 0.0,
675 ncost: 2,
676 coeffs: vec![24.035, -403.5, 0.0, 0.0, 0.0, 0.0],
677 };
678 let out = cost_to_pu(&cost, 100.0);
679 assert_eq!(out.len(), 2, "padding dropped");
680 assert!(approx(out[0], 2403.5)); assert!(approx(out[1], -403.5)); }
683
684 #[test]
685 fn cost_to_pu_piecewise_scales_mw_only_and_trims() {
686 let cost = GenCost {
688 model: 1,
689 startup: 0.0,
690 shutdown: 0.0,
691 ncost: 4,
692 coeffs: vec![
693 0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0, 0.0, 0.0,
694 ],
695 };
696 let out = cost_to_pu(&cost, 100.0);
697 assert_eq!(out.len(), 8, "trimmed to 2·ncost, padding dropped");
698 assert!(
699 approx(out[0], 0.0)
700 && approx(out[2], 1.0)
701 && approx(out[4], 2.0)
702 && approx(out[6], 2.5)
703 );
704 assert!(
705 approx(out[1], 0.0)
706 && approx(out[3], 2500.0)
707 && approx(out[5], 5500.0)
708 && approx(out[7], 7250.0)
709 );
710 }
711
712 #[test]
713 fn cost_rescale_round_trips() {
714 let cost = GenCost {
716 model: 2,
717 startup: 0.0,
718 shutdown: 0.0,
719 ncost: 3,
720 coeffs: vec![0.11, 5.0, 150.0],
721 };
722 let pu = cost_to_pu(&cost, 100.0);
723 assert!((pu[0] - 0.11 * 100.0 * 100.0).abs() < 1e-9);
725 assert!((pu[1] - 5.0 * 100.0).abs() < 1e-9);
726 assert!((pu[2] - 150.0).abs() < 1e-9);
727 let back = cost_from_pu(&pu, 2, 100.0);
728 for (a, b) in back.iter().zip(&cost.coeffs) {
729 assert!((a - b).abs() < 1e-9);
730 }
731 }
732
733 #[test]
734 fn cost_rescale_passes_through_unknown_model() {
735 let cost = GenCost {
739 model: 0,
740 startup: 0.0,
741 shutdown: 0.0,
742 ncost: 2,
743 coeffs: vec![3.0, 7.0, 9.0],
744 };
745 let pu = cost_to_pu(&cost, 100.0);
746 assert_eq!(pu, cost.coeffs, "to_pu must not scale an unknown model");
747 let back = cost_from_pu(&pu, cost.model, 100.0);
748 assert_eq!(back, cost.coeffs, "from_pu must not scale an unknown model");
749 }
750
751 #[test]
752 fn cost_rescale_round_trips_piecewise() {
753 let cost = GenCost {
757 model: 1,
758 startup: 0.0,
759 shutdown: 0.0,
760 ncost: 4,
761 coeffs: vec![0.0, 0.0, 100.0, 2500.0, 200.0, 5500.0, 250.0, 7250.0],
762 };
763 let pu = cost_to_pu(&cost, 100.0);
764 let back = cost_from_pu(&pu, 1, 100.0);
765 for (a, b) in back.iter().zip(&cost.coeffs) {
766 assert!((a - b).abs() < 1e-9, "{a} != {b}");
767 }
768 }
769}