Skip to main content

powerio_prob/instance/
ots.rs

1//! The AC optimal transmission switching instance.
2//!
3//! An [`AcOtsInstance`] is an AC optimal power flow problem whose branch
4//! statuses are decisions. It keeps every physical branch row of the network,
5//! takes the network's `in_service` bits as the initial topology, and states
6//! which rows may change status, what a change costs, how many changes and
7//! open rows are allowed, whether islands may form, and which branch current
8//! limits apply. It names no solver or formulation: a solver chooses the
9//! final branch statuses, [`AcOtsInstance::fix_topology`] turns them into the
10//! AC optimal power flow it then solves, and
11//! [`AcOtsSolution`](crate::AcOtsSolution) records the result.
12
13use 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/// Which physical branches may change status, by stable branch identity (the
27/// payload `uid`, else `branches:{row}`).
28#[derive(Clone, Debug, Default, PartialEq)]
29#[non_exhaustive]
30pub enum BranchSelection {
31    /// Every physical branch, including the initially open ones.
32    #[default]
33    All,
34    /// No branch; the topology is fixed.
35    None,
36    /// Exactly the named branches. Each identity must name one branch, once.
37    Only(Vec<String>),
38}
39
40impl BranchSelection {
41    /// The stable spelling of the selection kind: `all`, `none`, or `only`.
42    #[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/// The cost of changing one branch's status, in objective units. Only a
53/// change against the initial topology costs anything: opening an initially
54/// closed branch costs `open_cost`, closing an initially open one
55/// `close_cost`.
56#[derive(Clone, Debug, PartialEq)]
57#[non_exhaustive]
58pub struct BranchSwitchingCost {
59    /// The branch identity.
60    pub id: String,
61    /// The cost of opening the branch.
62    pub open_cost: f64,
63    /// The cost of closing the branch.
64    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/// Whether the final topology may split an AC island of the initial
79/// topology. An AC island is a set of energized buses joined by in-service
80/// branches; HVDC lines do not join islands, and a bus with no in-service
81/// branch is an island of its own.
82///
83/// Under either policy the initial topology's own islands are not a fault: a
84/// base network that already has several islands accepts its initial status
85/// vector. Under either policy a final topology is refused when it leaves a
86/// bus that an in-service generator supplied initially in an island with no
87/// in-service generator, because opening branches does not shed load or
88/// de-energize buses.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum IslandingPolicy {
92    /// No island of the initial topology may split: the buses of each
93    /// initial island stay in one island. The final topology may have fewer
94    /// islands than the initial one, because closing a branch can join them,
95    /// and islands that are separate initially may stay separate.
96    #[default]
97    Forbidden,
98    /// Islands may split. Each final island gets one reference bus and keeps
99    /// every load; no load is shed, and a feasible dispatch is not implied.
100    Allowed,
101}
102
103impl IslandingPolicy {
104    /// The stable spelling: `forbidden` or `allowed`.
105    #[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/// Which branch current limits the instance carries. Current limits are
115/// separate from the MVA thermal limits the base AC OPF selects.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum CurrentLimitPolicy {
119    /// No current limits.
120    #[default]
121    Skip,
122    /// The explicit [`BranchCurrentLimit`] records.
123    Stated,
124    /// The limits PowerModels' `calc_current_limits!` derives from the
125    /// active voltage, angle difference, and thermal limits.
126    DeriveFromRateA,
127}
128
129impl CurrentLimitPolicy {
130    /// The stable spelling: `skip`, `stated`, or `derive_from_rate_a`.
131    #[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/// An explicit current magnitude limit at a branch's two terminals, per unit
142/// on the system MVA base and each terminal bus's nominal voltage. `None` is
143/// unbounded, not zero. Source current ratings are never read as these.
144#[derive(Clone, Debug, PartialEq)]
145#[non_exhaustive]
146pub struct BranchCurrentLimit {
147    /// The branch identity.
148    pub id: String,
149    /// The from terminal limit, per unit.
150    pub from_pu: Option<f64>,
151    /// The to terminal limit, per unit.
152    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/// The switching options of an [`AcOtsInstance`].
167#[derive(Clone, Debug, Default, PartialEq)]
168#[non_exhaustive]
169pub struct AcOtsOptions {
170    /// The branches that may change status.
171    pub switchable: BranchSelection,
172    /// Transition costs; a branch with no record switches at zero cost.
173    pub switching_costs: Vec<BranchSwitchingCost>,
174    /// Whether the final topology may form islands.
175    pub islanding: IslandingPolicy,
176    /// The most branches open in the final topology, counting every open
177    /// row, including fixed ones.
178    pub max_open: Option<usize>,
179    /// The most status changes against the initial topology.
180    pub max_switched: Option<usize>,
181    /// Which current limits apply.
182    pub current_limits: CurrentLimitPolicy,
183    /// The explicit current limits [`CurrentLimitPolicy::Stated`] uses.
184    pub stated_current_limits: Vec<BranchCurrentLimit>,
185}
186
187/// The two terminal current bounds of one branch, per unit; `None` is
188/// unbounded.
189#[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/// The opening and closing cost of one branch.
203#[derive(Clone, Copy, Debug, PartialEq)]
204#[non_exhaustive]
205pub struct SwitchingCost {
206    pub open: f64,
207    pub close: f64,
208}
209
210/// One physical branch as the switching instance sees it, in branch table
211/// order.
212#[derive(Clone, Debug, PartialEq)]
213#[non_exhaustive]
214pub struct AcOtsBranch {
215    /// The stable branch identity.
216    pub id: String,
217    /// Whether the branch is in service in the initial topology.
218    pub initial_closed: bool,
219    /// Whether the branch may change status.
220    pub switchable: bool,
221    /// The stated transition costs, if any.
222    pub switching_cost: Option<SwitchingCost>,
223    /// The explicit current limits stated for the branch.
224    pub stated_current: TerminalCurrentBounds,
225    /// The current limits that apply under the instance's policy.
226    pub current: TerminalCurrentBounds,
227}
228
229/// The AC optimal transmission switching instance: an AC optimal power flow
230/// base, whose network supplies the candidate branches and their initial
231/// statuses, plus the switching options.
232///
233/// The base objective is kept exactly; switching costs enter the objective
234/// only when it states [`ObjectiveTerm::BranchSwitchingCost`]
235/// ([`with_switching_objective`](Self::with_switching_objective)).
236#[derive(Clone, Debug)]
237pub struct AcOtsInstance {
238    base: AcOpfInstance,
239    options: AcOtsOptions,
240    branches: Vec<AcOtsBranch>,
241    /// The AC islands of the initial topology.
242    initial_islands: IslandPartition,
243}
244
245impl AcOtsInstance {
246    /// Combine an AC optimal power flow base with explicit switching options.
247    /// The base objective is kept exactly.
248    ///
249    /// # Errors
250    /// An option names an unknown branch or names one twice, states a
251    /// negative or non-finite cost, a nonpositive or non-finite current
252    /// limit, a switching budget above the switchable branch count, or an
253    /// open budget below the number of fixed open branches.
254    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    /// The default switching instance over an AC optimal power flow base:
266    /// every initially in-service branch may switch, at zero cost, with no
267    /// budget, no islands, and no current limits, and the objective gains
268    /// the switching cost term.
269    ///
270    /// # Errors
271    /// As [`new`](Self::new).
272    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    /// The default switching instance over a network: the AC optimal power
281    /// flow [`AcOpfInstance::from_network`] states, with the defaults of
282    /// [`from_ac_opf`](Self::from_ac_opf).
283    ///
284    /// # Errors
285    /// As [`AcOpfInstance::from_network`] and [`new`](Self::new).
286    pub fn from_network(network: BalancedNetwork) -> Result<Self, Error> {
287        Self::from_ac_opf(AcOpfInstance::from_network(network)?)
288    }
289
290    /// Replace the switching options, keeping the base problem and its
291    /// objective.
292    ///
293    /// # Errors
294    /// As [`new`](Self::new).
295    pub fn with_options(self, options: AcOtsOptions) -> Result<Self, Error> {
296        Self::new(self.base, options)
297    }
298
299    /// Add the switching cost objective term, once.
300    #[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    /// The AC optimal power flow base: the network with its initial
315    /// topology, the objective, the constraint selections, and the initial
316    /// point.
317    #[must_use]
318    pub const fn base(&self) -> &AcOpfInstance {
319        &self.base
320    }
321
322    /// The network with every candidate branch at its initial status.
323    #[must_use]
324    pub fn network(&self) -> &BalancedNetwork {
325        self.base.network()
326    }
327
328    /// The typed objective.
329    #[must_use]
330    pub const fn objective(&self) -> &Objective {
331        self.base.objective()
332    }
333
334    /// The active constraint selections.
335    #[must_use]
336    pub const fn constraints(&self) -> &ActiveConstraints {
337        self.base.constraints()
338    }
339
340    /// The optional solver initial point.
341    #[must_use]
342    pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
343        self.base.initial_point()
344    }
345
346    /// The switching options as stated.
347    #[must_use]
348    pub const fn options(&self) -> &AcOtsOptions {
349        &self.options
350    }
351
352    /// Every physical branch in table order, with its resolved switching
353    /// settings.
354    #[must_use]
355    pub fn branches(&self) -> &[AcOtsBranch] {
356        &self.branches
357    }
358
359    /// The branch identities in table order.
360    pub fn branch_order(&self) -> impl ExactSizeIterator<Item = &str> {
361        self.branches.iter().map(|branch| branch.id.as_str())
362    }
363
364    /// The initial branch statuses in table order.
365    #[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    /// Check a complete final branch status vector, in branch table order,
374    /// against the fixed branches, the budgets, and the islanding policy, and
375    /// return its switching cost. This does not certify AC feasibility.
376    ///
377    /// The initial topology's own islands are not checked against the policy:
378    /// a base network with several islands accepts its initial statuses.
379    ///
380    /// # Errors
381    /// A vector of the wrong length, a changed fixed branch, a budget
382    /// exceeded, when islands are forbidden a final topology that splits an
383    /// island of the initial topology, or a final topology that leaves buses
384    /// supplied initially in an island with no in-service generator.
385    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    /// The AC optimal power flow over a validated final topology. Every
434    /// branch row stays in the network with its final status. When islands
435    /// are allowed, each final island gets exactly one reference bus. The
436    /// switching options, the current limits, and the switching cost term
437    /// are not part of the derived problem, and each discard is reported.
438    /// An initial point that states branch statuses describes the initial
439    /// topology, so it is discarded too.
440    ///
441    /// # Errors
442    /// As [`validate_topology`](Self::validate_topology).
443    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    /// The islanding rules for a final status vector of the right length.
488    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    /// The network with `branch_closed` as the branch statuses. The caller
522    /// checked the length.
523    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
539/// `Only` the branches the network states in service.
540fn 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
560/// Resolve the options to one row per physical branch, checking every
561/// identity, value, and budget.
562fn 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
650/// The budgets must leave at least one admissible topology and stay within
651/// the branch table.
652fn 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
681/// Set each branch's applicable current limits under `policy`.
682fn 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
706/// The per unit current limit of every branch, following PowerModels'
707/// `calc_current_limits!` (`src/core/data.jl`):
708///
709/// ```text
710/// c = |1/(r + jx)| · sqrt(vmax_f² + vmax_t² − 2·vmax_f·vmax_t·cos θmax)
711/// c = min(c, rate_a / min(vmin_f, vmin_t))      when rate_a > 0
712/// ```
713///
714/// `θmax` is the larger magnitude of the branch's angle difference bounds
715/// after PowerModels' parse time correction. Both terminals take the same
716/// limit. A term enters only when the constraints it rests on are active:
717/// the impedance term needs both terminals' voltage bounds (and uses the
718/// angle bounds when they are active, else `θmax = π`), and the rating term
719/// needs the branch's thermal limit and both terminals' voltage bounds. A
720/// branch with zero series impedance has no impedance term, where
721/// PowerModels' pseudo-inverse would state a zero limit.
722fn 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                // PowerModels' arithmetic, term for term.
761                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
779/// Give every island of `network` exactly one reference bus, the choice
780/// [`powerio_tx::Island::calc_reference_choice`] makes, else the island's
781/// smallest bus. Nothing is de-energized: an island with no generator keeps
782/// its loads. [`AcOtsInstance::validate_topology`] refuses a final island
783/// that switching leaves with no generator, so only an island that had none
784/// initially reaches the smallest bus fallback. Demoted references become PV
785/// when they host an in-service generator, else PQ.
786fn 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}