Skip to main content

powerio_prob/
dc.rs

1use serde::{Deserialize, Serialize};
2
3use powerio::{BusId, DcConvention, Error, IndexedNetwork, Result};
4
5/// Unit system for power and generator cost data.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
7#[non_exhaustive]
8pub enum Units {
9    /// Power is per unit. Cost coefficients are scaled for per unit power.
10    #[default]
11    PerUnit,
12    /// Power remains in the source unit, normally MW.
13    Native,
14}
15
16impl std::str::FromStr for Units {
17    type Err = String;
18
19    /// The one alias table for the bindings: `per-unit`/`perunit`/`pu` and
20    /// `native`, case insensitive, `-`/`_` ignored.
21    fn from_str(name: &str) -> std::result::Result<Self, Self::Err> {
22        match name.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
23            "perunit" | "pu" => Ok(Units::PerUnit),
24            "native" => Ok(Units::Native),
25            other => Err(format!(
26                "unknown units `{other}`; expected \"per-unit\" or \"native\""
27            )),
28        }
29    }
30}
31
32impl Units {
33    /// `(power, admittance)` multipliers for source data on `base` MVA. MW
34    /// valued quantities (demand, bounds, limits, MW valued shunts) scale by
35    /// the first; per unit admittances and susceptances by the second.
36    pub(crate) fn power_scales(self, base: f64) -> (f64, f64) {
37        match self {
38            Self::PerUnit => (1.0 / base, 1.0),
39            Self::Native => (1.0, base),
40        }
41    }
42
43    /// `(quadratic, linear)` generator cost coefficient multipliers for the
44    /// same unit selection. The constant term never scales.
45    pub(crate) fn cost_scales(self, base: f64) -> (f64, f64) {
46        match self {
47            Self::PerUnit => (base * base, base),
48            Self::Native => (1.0, 1.0),
49        }
50    }
51}
52
53/// Options for DC OPF instance assembly.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub struct DcOpfOptions {
56    pub convention: DcConvention,
57    pub units: Units,
58    /// Skip non-self-loop branches with zero reactance. If false, assembly
59    /// returns [`Error::ZeroImpedance`].
60    pub skip_zero_impedance: bool,
61}
62
63impl Default for DcOpfOptions {
64    fn default() -> Self {
65        Self {
66            convention: DcConvention::default(),
67            units: Units::default(),
68            skip_zero_impedance: true,
69        }
70    }
71}
72
73/// Generator data in generator column order.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct DcGeneratorData {
77    /// Generator column to dense bus index.
78    pub bus_of_gen: Vec<usize>,
79    /// Generator column to source generator row.
80    pub source_rows: Vec<usize>,
81    /// Quadratic objective diagonal in `0.5 * q * p^2 + c * p + c0`.
82    pub q: Vec<f64>,
83    /// Linear objective coefficient.
84    pub c: Vec<f64>,
85    /// Constant objective term. Unscaled in both unit systems: it carries no
86    /// power dimension. It does not move the argmin, but a consumer reporting
87    /// or comparing objective values needs it.
88    pub c0: Vec<f64>,
89    pub pmax: Vec<f64>,
90    pub pmin: Vec<f64>,
91}
92
93/// Branch data in active branch column order.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct DcBranchData {
97    pub from_bus: Vec<usize>,
98    pub to_bus: Vec<usize>,
99    /// Branch coefficient in the selected power unit per radian.
100    pub b: Vec<f64>,
101    /// Phase shift in radians. Zero under [`DcConvention::PaperPure`].
102    pub shift: Vec<f64>,
103    /// Thermal limit in the selected power unit. Zero means unlimited.
104    pub f_max: Vec<f64>,
105    /// Branch angle bounds in radians.
106    pub angle_min: Vec<f64>,
107    pub angle_max: Vec<f64>,
108    /// Branch column to source branch row.
109    pub source_rows: Vec<usize>,
110    /// Source branch rows omitted because their reactance was zero.
111    pub skipped_zero_impedance: Vec<usize>,
112}
113
114/// Exact nodal generator data for cases with at most one generator per bus.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[non_exhaustive]
117pub struct NodalGeneratorData {
118    pub q: Vec<f64>,
119    pub c: Vec<f64>,
120    pub c0: Vec<f64>,
121    pub pmax: Vec<f64>,
122    pub pmin: Vec<f64>,
123}
124
125/// Matrix free DC OPF input data.
126///
127/// A problem instance is complete numerical input for one problem family. It
128/// is separate from the source network, a matrix projection, a solver
129/// formulation, and a solution.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[non_exhaustive]
132pub struct DcOpfInstance {
133    pub name: String,
134    pub n_buses: usize,
135    pub n_source_generators: usize,
136    pub n_source_branches: usize,
137    pub base_mva: f64,
138    pub units: Units,
139    pub convention: DcConvention,
140    pub skip_zero_impedance: bool,
141    /// Dense bus index to external bus ID.
142    pub bus_ids: Vec<BusId>,
143    pub reference_buses: Vec<usize>,
144    /// Nodal active demand in dense bus order.
145    pub p_d: Vec<f64>,
146    /// Nodal phase shift injection in dense bus order.
147    pub p_shift: Vec<f64>,
148    pub generators: DcGeneratorData,
149    pub branches: DcBranchData,
150}
151
152impl DcOpfInstance {
153    #[must_use]
154    pub fn n_generators(&self) -> usize {
155        self.generators.q.len()
156    }
157
158    #[must_use]
159    pub fn n_branches(&self) -> usize {
160        self.branches.b.len()
161    }
162
163    /// Project generator cost and bounds to buses when the reduction is exact.
164    ///
165    /// A bus with several generators is rejected because summing their
166    /// quadratic coefficients does not preserve the original objective.
167    pub fn nodal_generator_data(&self) -> Result<NodalGeneratorData> {
168        let mut occupied = vec![false; self.n_buses];
169        let mut q = vec![0.0; self.n_buses];
170        let mut c = vec![0.0; self.n_buses];
171        let mut c0 = vec![0.0; self.n_buses];
172        let mut pmax = vec![0.0; self.n_buses];
173        let mut pmin = vec![0.0; self.n_buses];
174
175        for generator in 0..self.n_generators() {
176            let bus = self.generators.bus_of_gen[generator];
177            if occupied[bus] {
178                return Err(Error::MultipleGeneratorsAtBus {
179                    bus_id: self.bus_ids[bus],
180                });
181            }
182            occupied[bus] = true;
183            q[bus] = self.generators.q[generator];
184            c[bus] = self.generators.c[generator];
185            c0[bus] = self.generators.c0[generator];
186            pmax[bus] = self.generators.pmax[generator];
187            pmin[bus] = self.generators.pmin[generator];
188        }
189
190        Ok(NodalGeneratorData {
191            q,
192            c,
193            c0,
194            pmax,
195            pmin,
196        })
197    }
198}
199
200/// Build a matrix free DC OPF instance from an indexed network.
201#[allow(clippy::too_many_lines)]
202pub fn build_dc_opf_instance(
203    case: &IndexedNetwork,
204    options: &DcOpfOptions,
205) -> Result<DcOpfInstance> {
206    case.check_reference_coverage()?;
207    case.network().check_base_mva()?;
208
209    let n_buses = case.n();
210    let base = case.per_unit_base();
211    let (p_scale, b_scale) = options.units.power_scales(base);
212    let (q_scale, c_scale) = options.units.cost_scales(base);
213
214    let mut bus_of_gen = Vec::new();
215    let mut generator_rows = Vec::new();
216    let mut q = Vec::new();
217    let mut c = Vec::new();
218    let mut c0 = Vec::new();
219    let mut pmax = Vec::new();
220    let mut pmin = Vec::new();
221
222    for (source_row, generator) in case.in_service_gens() {
223        let bus = case.bus_index(generator.bus).ok_or(Error::UnknownBus {
224            bus_id: generator.bus,
225            element_index: source_row,
226        })?;
227        let cost = generator.cost.as_ref().ok_or(Error::MissingGenCost {
228            gen_index: source_row,
229        })?;
230        let (q_raw, c_raw, c0_raw) =
231            cost.quadratic_with_constant()
232                .ok_or(Error::UnsupportedCostModel {
233                    gen_index: source_row,
234                    model: cost.model,
235                    ncost: cost.ncost,
236                })?;
237        bus_of_gen.push(bus);
238        generator_rows.push(source_row);
239        q.push(q_raw * q_scale);
240        c.push(c_raw * c_scale);
241        c0.push(c0_raw);
242        pmax.push(generator.pmax * p_scale);
243        pmin.push(generator.pmin * p_scale);
244    }
245    if q.is_empty() {
246        return Err(Error::NoGenerators);
247    }
248
249    let mut from_bus = Vec::new();
250    let mut to_bus = Vec::new();
251    let mut b = Vec::new();
252    let mut shift = Vec::new();
253    let mut f_max = Vec::new();
254    let mut angle_min = Vec::new();
255    let mut angle_max = Vec::new();
256    let mut branch_rows = Vec::new();
257    let mut skipped_zero_impedance = Vec::new();
258    let mut p_shift = vec![0.0; n_buses];
259
260    for (source_row, branch) in case.in_service_branches() {
261        let from = case.bus_index(branch.from).ok_or(Error::UnknownBus {
262            bus_id: branch.from,
263            element_index: source_row,
264        })?;
265        let to = case.bus_index(branch.to).ok_or(Error::UnknownBus {
266            bus_id: branch.to,
267            element_index: source_row,
268        })?;
269        if from == to {
270            // A self-loop carries no angle difference, so it contributes no
271            // DC flow, and its shift injection cancels at its own bus.
272            continue;
273        }
274        if branch.x == 0.0 {
275            if options.skip_zero_impedance {
276                skipped_zero_impedance.push(source_row);
277                continue;
278            }
279            return Err(Error::ZeroImpedance { row: source_row });
280        }
281        let branch_b = options
282            .convention
283            .branch_susceptance(branch.x, branch.effective_tap())
284            * b_scale;
285        if !branch_b.is_finite() {
286            return Err(Error::NonFiniteSusceptance { row: source_row });
287        }
288        let shift_rad = if options.convention.includes_phase_shifts() {
289            case.angle_radians(branch.shift)
290        } else {
291            0.0
292        };
293        if shift_rad != 0.0 {
294            p_shift[from] -= branch_b * shift_rad;
295            p_shift[to] += branch_b * shift_rad;
296        }
297        from_bus.push(from);
298        to_bus.push(to);
299        b.push(branch_b);
300        shift.push(shift_rad);
301        f_max.push(branch.rate_a * p_scale);
302        angle_min.push(case.angle_radians(branch.angmin));
303        angle_max.push(case.angle_radians(branch.angmax));
304        branch_rows.push(source_row);
305    }
306
307    Ok(DcOpfInstance {
308        name: case.name().to_owned(),
309        n_buses,
310        n_source_generators: case.generators().len(),
311        n_source_branches: case.branches().len(),
312        base_mva: case.base_mva(),
313        units: options.units,
314        convention: options.convention,
315        skip_zero_impedance: options.skip_zero_impedance,
316        bus_ids: (0..n_buses).map(|index| case.bus_id(index)).collect(),
317        reference_buses: case.reference_bus_indices(),
318        p_d: case.pd().iter().map(|value| value * p_scale).collect(),
319        p_shift,
320        generators: DcGeneratorData {
321            bus_of_gen,
322            source_rows: generator_rows,
323            q,
324            c,
325            c0,
326            pmax,
327            pmin,
328        },
329        branches: DcBranchData {
330            from_bus,
331            to_bus,
332            b,
333            shift,
334            f_max,
335            angle_min,
336            angle_max,
337            source_rows: branch_rows,
338            skipped_zero_impedance,
339        },
340    })
341}