1use std::collections::{BTreeSet, HashMap};
14
15use powerio_core::{Diagnostic, Error};
16use powerio_tx::{
17 BalancedNetwork, BusId, BusType, IslandPartition, correct_angle_difference_bounds,
18};
19
20use crate::diagnostics::codes;
21use crate::instance::balanced::{AcOpfInstance, transform_assumption, transform_discarded};
22use crate::instance::constraints::ActiveConstraints;
23use crate::instance::objective::{Objective, ObjectiveTerm};
24use crate::operating::{BalancedOperatingPointFlag, OperatingPoint, row_identity};
25
26#[derive(Clone, Debug, Default, PartialEq)]
29#[non_exhaustive]
30pub enum BranchSelection {
31 #[default]
33 All,
34 None,
36 Only(Vec<String>),
38}
39
40impl BranchSelection {
41 #[must_use]
43 pub const fn kind_name(&self) -> &'static str {
44 match self {
45 Self::All => "all",
46 Self::None => "none",
47 Self::Only(_) => "only",
48 }
49 }
50}
51
52#[derive(Clone, Debug, PartialEq)]
57#[non_exhaustive]
58pub struct BranchSwitchingCost {
59 pub id: String,
61 pub open_cost: f64,
63 pub close_cost: f64,
65}
66
67impl BranchSwitchingCost {
68 #[must_use]
69 pub fn new(id: impl Into<String>, open_cost: f64, close_cost: f64) -> Self {
70 Self {
71 id: id.into(),
72 open_cost,
73 close_cost,
74 }
75 }
76}
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum IslandingPolicy {
92 #[default]
97 Forbidden,
98 Allowed,
101}
102
103impl IslandingPolicy {
104 #[must_use]
106 pub const fn name(self) -> &'static str {
107 match self {
108 Self::Forbidden => "forbidden",
109 Self::Allowed => "allowed",
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum CurrentLimitPolicy {
119 #[default]
121 Skip,
122 Stated,
124 DeriveFromRateA,
127}
128
129impl CurrentLimitPolicy {
130 #[must_use]
132 pub const fn name(self) -> &'static str {
133 match self {
134 Self::Skip => "skip",
135 Self::Stated => "stated",
136 Self::DeriveFromRateA => "derive_from_rate_a",
137 }
138 }
139}
140
141#[derive(Clone, Debug, PartialEq)]
145#[non_exhaustive]
146pub struct BranchCurrentLimit {
147 pub id: String,
149 pub from_pu: Option<f64>,
151 pub to_pu: Option<f64>,
153}
154
155impl BranchCurrentLimit {
156 #[must_use]
157 pub fn new(id: impl Into<String>, from_pu: Option<f64>, to_pu: Option<f64>) -> Self {
158 Self {
159 id: id.into(),
160 from_pu,
161 to_pu,
162 }
163 }
164}
165
166#[derive(Clone, Debug, Default, PartialEq)]
168#[non_exhaustive]
169pub struct AcOtsOptions {
170 pub switchable: BranchSelection,
172 pub switching_costs: Vec<BranchSwitchingCost>,
174 pub islanding: IslandingPolicy,
176 pub max_open: Option<usize>,
179 pub max_switched: Option<usize>,
181 pub current_limits: CurrentLimitPolicy,
183 pub stated_current_limits: Vec<BranchCurrentLimit>,
185}
186
187#[derive(Clone, Copy, Debug, Default, PartialEq)]
190#[non_exhaustive]
191pub struct TerminalCurrentBounds {
192 pub from_pu: Option<f64>,
193 pub to_pu: Option<f64>,
194}
195
196impl TerminalCurrentBounds {
197 fn is_bounded(self) -> bool {
198 self.from_pu.is_some() || self.to_pu.is_some()
199 }
200}
201
202#[derive(Clone, Copy, Debug, PartialEq)]
204#[non_exhaustive]
205pub struct SwitchingCost {
206 pub open: f64,
207 pub close: f64,
208}
209
210#[derive(Clone, Debug, PartialEq)]
213#[non_exhaustive]
214pub struct AcOtsBranch {
215 pub id: String,
217 pub initial_closed: bool,
219 pub switchable: bool,
221 pub switching_cost: Option<SwitchingCost>,
223 pub stated_current: TerminalCurrentBounds,
225 pub current: TerminalCurrentBounds,
227}
228
229#[derive(Clone, Debug)]
237pub struct AcOtsInstance {
238 base: AcOpfInstance,
239 options: AcOtsOptions,
240 branches: Vec<AcOtsBranch>,
241 initial_islands: IslandPartition,
243}
244
245impl AcOtsInstance {
246 pub fn new(base: AcOpfInstance, options: AcOtsOptions) -> Result<Self, Error> {
255 let branches = resolve_branches(&base, &options)?;
256 let initial_islands = base.network().calc_islands();
257 Ok(Self {
258 base,
259 options,
260 branches,
261 initial_islands,
262 })
263 }
264
265 pub fn from_ac_opf(base: AcOpfInstance) -> Result<Self, Error> {
273 let options = AcOtsOptions {
274 switchable: initially_closed_selection(base.network()),
275 ..AcOtsOptions::default()
276 };
277 Ok(Self::new(base, options)?.with_switching_objective())
278 }
279
280 pub fn from_network(network: BalancedNetwork) -> Result<Self, Error> {
287 Self::from_ac_opf(AcOpfInstance::from_network(network)?)
288 }
289
290 pub fn with_options(self, options: AcOtsOptions) -> Result<Self, Error> {
296 Self::new(self.base, options)
297 }
298
299 #[must_use]
301 pub fn with_switching_objective(mut self) -> Self {
302 if !self
303 .base
304 .objective()
305 .contains(&ObjectiveTerm::BranchSwitchingCost)
306 {
307 self.base = self
308 .base
309 .with_objective_term(ObjectiveTerm::BranchSwitchingCost);
310 }
311 self
312 }
313
314 #[must_use]
318 pub const fn base(&self) -> &AcOpfInstance {
319 &self.base
320 }
321
322 #[must_use]
324 pub fn network(&self) -> &BalancedNetwork {
325 self.base.network()
326 }
327
328 #[must_use]
330 pub const fn objective(&self) -> &Objective {
331 self.base.objective()
332 }
333
334 #[must_use]
336 pub const fn constraints(&self) -> &ActiveConstraints {
337 self.base.constraints()
338 }
339
340 #[must_use]
342 pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
343 self.base.initial_point()
344 }
345
346 #[must_use]
348 pub const fn options(&self) -> &AcOtsOptions {
349 &self.options
350 }
351
352 #[must_use]
355 pub fn branches(&self) -> &[AcOtsBranch] {
356 &self.branches
357 }
358
359 pub fn branch_order(&self) -> impl ExactSizeIterator<Item = &str> {
361 self.branches.iter().map(|branch| branch.id.as_str())
362 }
363
364 #[must_use]
366 pub fn initial_closed(&self) -> Vec<bool> {
367 self.branches
368 .iter()
369 .map(|branch| branch.initial_closed)
370 .collect()
371 }
372
373 pub fn validate_topology(&self, branch_closed: &[bool]) -> Result<f64, Error> {
386 if branch_closed.len() != self.branches.len() {
387 return Err(topology_invalid(format!(
388 "the branch status vector carries {} values; the network has {} branches",
389 branch_closed.len(),
390 self.branches.len()
391 )));
392 }
393 let mut open = 0;
394 let mut switched = 0;
395 let mut cost = 0.0;
396 for (branch, &closed) in self.branches.iter().zip(branch_closed) {
397 if !closed {
398 open += 1;
399 }
400 if closed == branch.initial_closed {
401 continue;
402 }
403 if !branch.switchable {
404 return Err(topology_invalid(format!(
405 "branch `{}` cannot switch, but the final topology {} it",
406 branch.id,
407 if closed { "closes" } else { "opens" }
408 )));
409 }
410 switched += 1;
411 if let Some(costs) = branch.switching_cost {
412 cost += if closed { costs.close } else { costs.open };
413 }
414 }
415 if let Some(max_open) = self.options.max_open
416 && open > max_open
417 {
418 return Err(topology_invalid(format!(
419 "the final topology opens {open} branches; at most {max_open} may be open"
420 )));
421 }
422 if let Some(max_switched) = self.options.max_switched
423 && switched > max_switched
424 {
425 return Err(topology_invalid(format!(
426 "the final topology changes {switched} branch statuses; at most {max_switched} may change"
427 )));
428 }
429 self.check_islands(branch_closed)?;
430 Ok(cost)
431 }
432
433 pub fn fix_topology(
444 &self,
445 branch_closed: &[bool],
446 ) -> Result<(AcOpfInstance, Vec<Diagnostic>), Error> {
447 self.validate_topology(branch_closed)?;
448 let mut network = self.network_with(branch_closed);
449 let mut diagnostics = vec![transform_discarded(
450 "the switchable branch selection, switching costs, switching budgets, and islanding policy",
451 )];
452 if self.options.islanding == IslandingPolicy::Allowed {
453 diagnostics.extend(assign_island_references(&mut network));
454 }
455 if self
456 .branches
457 .iter()
458 .any(|branch| branch.current.is_bounded())
459 {
460 diagnostics.push(transform_discarded("the branch current limits"));
461 }
462 let mut objective = self.base.objective().clone();
463 if objective.contains(&ObjectiveTerm::BranchSwitchingCost) {
464 objective = objective.without(&ObjectiveTerm::BranchSwitchingCost);
465 diagnostics.push(transform_discarded(
466 "the branch switching cost objective term",
467 ));
468 }
469 let mut fixed = AcOpfInstance::from_network(network.clone())?
470 .with_objective(objective)
471 .with_constraints(self.base.constraints().clone());
472 if let Some(point) = self.base.initial_point() {
473 if point
474 .flags(BalancedOperatingPointFlag::BranchInService)
475 .is_some()
476 {
477 diagnostics.push(transform_discarded(
478 "the initial point, which states the initial branch statuses,",
479 ));
480 } else {
481 fixed = fixed.with_initial_point(point.clone().rebind_network(network)?);
482 }
483 }
484 Ok((fixed, diagnostics))
485 }
486
487 fn check_islands(&self, branch_closed: &[bool]) -> Result<(), Error> {
489 let initial = &self.initial_islands;
490 let fin = self.network_with(branch_closed).calc_islands();
491 if self.options.islanding == IslandingPolicy::Forbidden {
492 for island in &initial.islands {
493 let pieces: BTreeSet<Option<usize>> =
494 island.buses.iter().map(|&bus| fin.island_of(bus)).collect();
495 if pieces.len() > 1 {
496 return Err(topology_invalid(format!(
497 "the final topology splits the initial island of {} buses holding bus {} into {} islands, and islanding is forbidden",
498 island.buses.len(),
499 island.buses[0],
500 pieces.len()
501 )));
502 }
503 }
504 }
505 for island in fin.islands.iter().filter(|island| !island.is_supplied()) {
506 let cut_off = island.buses.iter().find(|&&bus| {
507 initial
508 .island_of(bus)
509 .is_some_and(|position| initial.islands[position].is_supplied())
510 });
511 if let Some(bus) = cut_off {
512 return Err(topology_invalid(format!(
513 "the final topology leaves an island of {} buses holding bus {bus} with no in-service generator; opening branches does not shed load or de-energize buses",
514 island.buses.len()
515 )));
516 }
517 }
518 Ok(())
519 }
520
521 fn network_with(&self, branch_closed: &[bool]) -> BalancedNetwork {
524 let mut network = self.base.network().clone();
525 let changed = network
526 .branches()
527 .iter()
528 .zip(branch_closed)
529 .any(|(branch, &closed)| branch.in_service != closed);
530 if changed {
531 for (branch, &closed) in network.branches_mut().iter_mut().zip(branch_closed) {
532 branch.in_service = closed;
533 }
534 }
535 network
536 }
537}
538
539fn initially_closed_selection(network: &BalancedNetwork) -> BranchSelection {
541 BranchSelection::Only(
542 network
543 .branches()
544 .iter()
545 .enumerate()
546 .filter(|(_, branch)| branch.in_service)
547 .map(|(row, branch)| row_identity(branch.uid.as_deref(), "branches", row))
548 .collect(),
549 )
550}
551
552fn options_invalid(message: String) -> Error {
553 Error::new(&codes::BUILD_INSTANCE_SWITCHING_OPTIONS_INVALID, message)
554}
555
556fn topology_invalid(message: String) -> Error {
557 Error::new(&codes::BUILD_INSTANCE_TOPOLOGY_INVALID, message)
558}
559
560fn resolve_branches(
563 base: &AcOpfInstance,
564 options: &AcOtsOptions,
565) -> Result<Vec<AcOtsBranch>, Error> {
566 let network = base.network();
567 let mut branches: Vec<AcOtsBranch> = network
568 .branches()
569 .iter()
570 .enumerate()
571 .map(|(row, branch)| AcOtsBranch {
572 id: row_identity(branch.uid.as_deref(), "branches", row),
573 initial_closed: branch.in_service,
574 switchable: matches!(options.switchable, BranchSelection::All),
575 switching_cost: None,
576 stated_current: TerminalCurrentBounds::default(),
577 current: TerminalCurrentBounds::default(),
578 })
579 .collect();
580 let mut position = HashMap::with_capacity(branches.len());
581 for (row, branch) in branches.iter().enumerate() {
582 if position.insert(branch.id.clone(), row).is_some() {
583 return Err(Error::new(
584 &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
585 format!("two branches share the identity `{}`", branch.id),
586 ));
587 }
588 }
589 let resolve = |what: &str, id: &str, seen: &mut BTreeSet<usize>| -> Result<usize, Error> {
590 let row = *position
591 .get(id)
592 .ok_or_else(|| options_invalid(format!("{what} names unknown branch `{id}`")))?;
593 if !seen.insert(row) {
594 return Err(options_invalid(format!(
595 "{what} names branch `{id}` more than once"
596 )));
597 }
598 Ok(row)
599 };
600
601 if let BranchSelection::Only(identities) = &options.switchable {
602 let mut seen = BTreeSet::new();
603 for id in identities {
604 let row = resolve("the switchable selection", id, &mut seen)?;
605 branches[row].switchable = true;
606 }
607 }
608
609 let mut seen = BTreeSet::new();
610 for cost in &options.switching_costs {
611 let row = resolve("the switching costs", &cost.id, &mut seen)?;
612 for (side, value) in [("opening", cost.open_cost), ("closing", cost.close_cost)] {
613 if !value.is_finite() || value < 0.0 {
614 return Err(options_invalid(format!(
615 "branch `{}` has {side} cost {value}; costs must be finite and nonnegative",
616 cost.id
617 )));
618 }
619 }
620 branches[row].switching_cost = Some(SwitchingCost {
621 open: cost.open_cost,
622 close: cost.close_cost,
623 });
624 }
625
626 let mut seen = BTreeSet::new();
627 for limit in &options.stated_current_limits {
628 let row = resolve("the stated current limits", &limit.id, &mut seen)?;
629 for (side, value) in [("from", limit.from_pu), ("to", limit.to_pu)] {
630 if let Some(value) = value
631 && (!value.is_finite() || value <= 0.0)
632 {
633 return Err(options_invalid(format!(
634 "branch `{}` has {side} terminal current limit {value}; a limit must be finite and positive",
635 limit.id
636 )));
637 }
638 }
639 branches[row].stated_current = TerminalCurrentBounds {
640 from_pu: limit.from_pu,
641 to_pu: limit.to_pu,
642 };
643 }
644
645 check_budgets(&branches, options)?;
646 apply_current_policy(base, options.current_limits, &mut branches);
647 Ok(branches)
648}
649
650fn check_budgets(branches: &[AcOtsBranch], options: &AcOtsOptions) -> Result<(), Error> {
653 let switchable = branches.iter().filter(|branch| branch.switchable).count();
654 let fixed_open = branches
655 .iter()
656 .filter(|branch| !branch.switchable && !branch.initial_closed)
657 .count();
658 if let Some(max_switched) = options.max_switched
659 && max_switched > switchable
660 {
661 return Err(options_invalid(format!(
662 "the switching budget {max_switched} exceeds the {switchable} switchable branches"
663 )));
664 }
665 if let Some(max_open) = options.max_open {
666 if max_open > branches.len() {
667 return Err(options_invalid(format!(
668 "the open budget {max_open} exceeds the {} branches",
669 branches.len()
670 )));
671 }
672 if max_open < fixed_open {
673 return Err(options_invalid(format!(
674 "the open budget {max_open} is below the {fixed_open} branches that are open and cannot switch"
675 )));
676 }
677 }
678 Ok(())
679}
680
681fn apply_current_policy(
683 base: &AcOpfInstance,
684 policy: CurrentLimitPolicy,
685 branches: &mut [AcOtsBranch],
686) {
687 match policy {
688 CurrentLimitPolicy::Skip => {}
689 CurrentLimitPolicy::Stated => {
690 for branch in branches.iter_mut() {
691 branch.current = branch.stated_current;
692 }
693 }
694 CurrentLimitPolicy::DeriveFromRateA => {
695 let derived = derive_current_limits(base);
696 for (branch, limit) in branches.iter_mut().zip(derived) {
697 branch.current = TerminalCurrentBounds {
698 from_pu: limit,
699 to_pu: limit,
700 };
701 }
702 }
703 }
704}
705
706fn derive_current_limits(base: &AcOpfInstance) -> Vec<Option<f64>> {
723 let network = base.network();
724 let constraints = base.constraints();
725 let normalized = network.is_normalized();
726 let base_mva = network.base_mva();
727 let buses: HashMap<BusId, (f64, f64, bool)> = network
728 .buses()
729 .iter()
730 .map(|bus| {
731 let active = constraints.voltage_bounds.selects(&bus.id.to_string());
732 (bus.id, (bus.vmin, bus.vmax, active))
733 })
734 .collect();
735 network
736 .branches()
737 .iter()
738 .enumerate()
739 .map(|(row, branch)| {
740 let identity = row_identity(branch.uid.as_deref(), "branches", row);
741 let (&(vmin_f, vmax_f, active_f), &(vmin_t, vmax_t, active_t)) =
742 (buses.get(&branch.from)?, buses.get(&branch.to)?);
743 if !(active_f && active_t) {
744 return None;
745 }
746 let mut limit: Option<f64> = None;
747 let impedance = branch.r.hypot(branch.x);
748 if impedance > 0.0 {
749 let theta_max = if constraints.angle_bounds.selects(&identity) {
750 let (angmin, angmax) = if normalized {
751 (branch.angmin, branch.angmax)
752 } else {
753 (branch.angmin.to_radians(), branch.angmax.to_radians())
754 };
755 let (angmin, angmax) = correct_angle_difference_bounds(angmin, angmax);
756 angmin.abs().max(angmax.abs())
757 } else {
758 std::f64::consts::PI
759 };
760 let spread =
762 vmax_f * vmax_f + vmax_t * vmax_t - 2.0 * vmax_f * vmax_t * theta_max.cos();
763 limit = Some(spread.max(0.0).sqrt() / impedance);
764 }
765 if branch.rate_a > 0.0 && constraints.thermal_limits.selects(&identity) {
766 let rate = if normalized {
767 branch.rate_a
768 } else {
769 branch.rate_a / base_mva
770 };
771 let rated = rate / vmin_f.min(vmin_t);
772 limit = Some(limit.map_or(rated, |limit| limit.min(rated)));
773 }
774 limit.filter(|limit| limit.is_finite())
775 })
776 .collect()
777}
778
779fn assign_island_references(network: &mut BalancedNetwork) -> Vec<Diagnostic> {
787 let partition = network.calc_islands();
788 let hosts_generator: BTreeSet<BusId> = network
789 .generators()
790 .iter()
791 .filter(|generator| generator.in_service)
792 .map(|generator| generator.bus)
793 .collect();
794 let mut kinds: HashMap<BusId, BusType> = HashMap::new();
795 let mut diagnostics = Vec::new();
796 for island in &partition.islands {
797 let chosen = island
798 .calc_reference_choice(network.generators())
799 .unwrap_or(island.buses[0]);
800 if island.references.as_slice() == [chosen] {
801 continue;
802 }
803 kinds.insert(chosen, BusType::Ref);
804 for &bus in island.references.iter().filter(|&&bus| bus != chosen) {
805 let kind = if hosts_generator.contains(&bus) {
806 BusType::Pv
807 } else {
808 BusType::Pq
809 };
810 kinds.insert(bus, kind);
811 }
812 diagnostics.push(transform_assumption(&format!(
813 "the final island of {} bus(es) takes bus {chosen} as its one reference bus",
814 island.buses.len()
815 )));
816 }
817 if !kinds.is_empty() {
818 for bus in network.buses_mut() {
819 if let Some(&kind) = kinds.get(&bus.id) {
820 bus.kind = kind;
821 }
822 }
823 }
824 diagnostics
825}