Skip to main content

powerio_prob/solution/
ots.rs

1//! The AC optimal transmission switching solution.
2//!
3//! An [`AcOtsSolution`] pairs a final branch status vector with the AC
4//! optimal power flow solution of the problem
5//! [`AcOtsInstance::fix_topology`] derives from it. Construction checks that
6//! the topology is valid for the instance, that the AC solution solves
7//! exactly that fixed problem, and that open branches carry no flow. Finiteness
8//! is [`AcOpfSolution`]'s own rule: values it accepts, such as the NaN rows of
9//! an out-of-service generator or a non-converged result, are accepted here.
10//! It does not check power flow residuals or optimality.
11
12use std::sync::Arc;
13
14use powerio_core::Error;
15use powerio_tx::BalancedNetwork;
16
17use crate::diagnostics::codes;
18use crate::instance::{AcOpfInstance, AcOtsInstance, ObjectiveTerm};
19use crate::solution::{AcOpfSolution, Residuals, Termination};
20
21/// The AC optimal transmission switching result: the final branch statuses,
22/// their switching cost, and the AC optimal power flow solution over that
23/// topology.
24#[derive(Clone, Debug)]
25pub struct AcOtsSolution {
26    instance: Arc<AcOtsInstance>,
27    branch_closed: Vec<bool>,
28    ac_solution: AcOpfSolution,
29    switching_cost: f64,
30}
31
32impl AcOtsSolution {
33    /// Attach the AC optimal power flow solution computed on
34    /// `instance.fix_topology(&branch_closed)`.
35    ///
36    /// # Errors
37    /// The topology fails [`AcOtsInstance::validate_topology`], the AC
38    /// solution answers a different problem than the fixed topology states
39    /// (its network, objective, or constraint selections differ), or an open
40    /// branch carries flow. A NaN flow on an open branch means no flow was
41    /// recorded and is accepted; a NaN anywhere else is left to
42    /// [`AcOpfSolution`].
43    pub fn new(
44        instance: Arc<AcOtsInstance>,
45        branch_closed: Vec<bool>,
46        ac_solution: AcOpfSolution,
47    ) -> Result<Self, Error> {
48        let switching_cost = instance.validate_topology(&branch_closed)?;
49        let (fixed, _) = instance.fix_topology(&branch_closed)?;
50        require_same_problem(&fixed, ac_solution.instance())?;
51        require_open_branches_idle(&instance, &branch_closed, &ac_solution)?;
52        Ok(Self {
53            instance,
54            branch_closed,
55            ac_solution,
56            switching_cost,
57        })
58    }
59
60    /// The switching instance this solution solves. Borrowed; never a copy.
61    #[must_use]
62    pub fn instance(&self) -> &AcOtsInstance {
63        &self.instance
64    }
65
66    /// The shared instance owner.
67    #[must_use]
68    pub fn shared_instance(&self) -> Arc<AcOtsInstance> {
69        Arc::clone(&self.instance)
70    }
71
72    /// The instance's network: every candidate branch at its initial
73    /// status. The final statuses are [`branch_closed`](Self::branch_closed);
74    /// the AC solution's instance holds the network with them applied.
75    #[must_use]
76    pub fn network(&self) -> &BalancedNetwork {
77        self.instance.network()
78    }
79
80    /// The final branch statuses, in branch table order.
81    #[must_use]
82    pub fn branch_closed(&self) -> &[bool] {
83        &self.branch_closed
84    }
85
86    /// The AC optimal power flow solution over the final topology.
87    #[must_use]
88    pub const fn ac_solution(&self) -> &AcOpfSolution {
89        &self.ac_solution
90    }
91
92    /// The cost of the status changes against the initial topology.
93    #[must_use]
94    pub const fn switching_cost(&self) -> f64 {
95        self.switching_cost
96    }
97
98    /// The number of branches open in the final topology.
99    #[must_use]
100    pub fn branches_open(&self) -> usize {
101        self.branch_closed.iter().filter(|closed| !**closed).count()
102    }
103
104    /// The number of branches whose final status differs from the initial
105    /// one.
106    #[must_use]
107    pub fn branches_switched(&self) -> usize {
108        self.instance
109            .branches()
110            .iter()
111            .zip(&self.branch_closed)
112            .filter(|(branch, closed)| branch.initial_closed != **closed)
113            .count()
114    }
115
116    /// The objective value: the AC solution's objective, plus the switching
117    /// cost when the instance's objective states the switching cost term.
118    #[must_use]
119    pub fn objective(&self) -> f64 {
120        let switching = if self
121            .instance
122            .objective()
123            .contains(&ObjectiveTerm::BranchSwitchingCost)
124        {
125            self.switching_cost
126        } else {
127            0.0
128        };
129        self.ac_solution.objective() + switching
130    }
131
132    /// How the producing calculation ended, as the AC solution records it.
133    #[must_use]
134    pub fn termination(&self) -> &Termination {
135        self.ac_solution.termination()
136    }
137
138    /// The AC solution's numerical residuals.
139    #[must_use]
140    pub fn residuals(&self) -> &Residuals {
141        self.ac_solution.residuals()
142    }
143
144    /// The producer or solver identity the AC solution records.
145    #[must_use]
146    pub fn producer(&self) -> Option<&str> {
147        self.ac_solution.producer()
148    }
149}
150
151fn mismatch(message: String) -> Error {
152    Error::new(&codes::BUILD_SOLUTION_TOPOLOGY_MISMATCH, message)
153}
154
155/// The two AC problems agree on the network, the objective, and the
156/// constraint selections. The initial point is a solver hint, not part of
157/// the problem, so it is not compared. Networks compare by their stored
158/// form, which a PowerIO IR round trip preserves.
159fn require_same_problem(fixed: &AcOpfInstance, solved: &AcOpfInstance) -> Result<(), Error> {
160    if fixed.objective() != solved.objective() {
161        return Err(mismatch(
162            "the AC solution's objective differs from the fixed-topology problem's".to_owned(),
163        ));
164    }
165    if fixed.constraints() != solved.constraints() {
166        return Err(mismatch(
167            "the AC solution's constraint selections differ from the fixed-topology problem's"
168                .to_owned(),
169        ));
170    }
171    let stored = |network: &BalancedNetwork| {
172        serde_json::to_value(network)
173            .map_err(|error| mismatch(format!("a network could not be compared: {error}")))
174    };
175    if stored(fixed.network())? != stored(solved.network())? {
176        let detail = match fixed
177            .network()
178            .branches()
179            .iter()
180            .zip(solved.network().branches())
181            .position(|(a, b)| a.in_service != b.in_service)
182        {
183            Some(row) => format!(", first at branch row {row}'s status"),
184            None => String::new(),
185        };
186        return Err(mismatch(format!(
187            "the AC solution's network differs from the fixed-topology network{detail}"
188        )));
189    }
190    Ok(())
191}
192
193#[allow(clippy::float_cmp)] // an open branch carries exactly zero flow
194fn require_open_branches_idle(
195    instance: &AcOtsInstance,
196    branch_closed: &[bool],
197    solution: &AcOpfSolution,
198) -> Result<(), Error> {
199    let flows = [
200        solution.branch_from_active_flows(),
201        solution.branch_from_reactive_flows(),
202        solution.branch_to_active_flows(),
203        solution.branch_to_reactive_flows(),
204    ];
205    for (row, (branch, &closed)) in instance.branches().iter().zip(branch_closed).enumerate() {
206        // A NaN compares unequal to zero, but records no flow.
207        if !closed
208            && flows
209                .iter()
210                .any(|column| !column[row].is_nan() && column[row] != 0.0)
211        {
212            return Err(Error::new(
213                &codes::BUILD_SOLUTION_VALUE_INVALID,
214                format!(
215                    "branch `{}` is open in the final topology but carries flow",
216                    branch.id
217                ),
218            ));
219        }
220    }
221    Ok(())
222}