Skip to main content

powerio_prob/
ac.rs

1use serde::{Deserialize, Serialize};
2
3use powerio::{BusId, IndexedNetwork};
4
5use crate::{Error, Result};
6
7use crate::{ReferenceBuses, Units, limits, nodal};
8
9/// Options for AC OPF instance assembly.
10///
11/// There is no convention enum: the branch pi model always carries taps,
12/// shifts, and charging.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub struct AcOpfOptions {
15    pub units: Units,
16    /// Skip non-self-loop branches with `r² + x² = 0`. If false, assembly
17    /// returns [`powerio::Error::ZeroImpedance`].
18    pub skip_zero_impedance: bool,
19    /// Give a branch with no thermal rating the bound
20    /// [`Branch::synthesize_rate_a`](powerio::Branch::synthesize_rate_a)
21    /// states. If false, `rate_a <= 0` reaches `s_max` as zero, which reads as
22    /// unlimited.
23    pub synthesize_unrated_limits: bool,
24}
25
26impl Default for AcOpfOptions {
27    fn default() -> Self {
28        Self {
29            units: Units::default(),
30            skip_zero_impedance: true,
31            synthesize_unrated_limits: false,
32        }
33    }
34}
35
36/// Bus data in dense bus order.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[non_exhaustive]
39pub struct AcBusData {
40    /// Nodal active demand in the selected power unit.
41    pub p_d: Vec<f64>,
42    /// Nodal reactive demand in the selected power unit.
43    pub q_d: Vec<f64>,
44    /// Nodal shunt conductance in the selected admittance unit. Includes the
45    /// folded pi model stamp of any self-loop branch, matching `build_ybus`.
46    pub g_s: Vec<f64>,
47    /// Nodal shunt susceptance in the selected admittance unit. Includes the
48    /// folded pi model stamp of any self-loop branch, matching `build_ybus`.
49    pub b_s: Vec<f64>,
50    /// Voltage magnitude lower bound, per unit.
51    pub vm_min: Vec<f64>,
52    /// Voltage magnitude upper bound, per unit.
53    pub vm_max: Vec<f64>,
54    /// Case voltage magnitude, per unit: the raw initial guess, zero when the
55    /// source has none.
56    pub vm: Vec<f64>,
57}
58
59/// Branch data in active branch column order.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[non_exhaustive]
62pub struct AcBranchData {
63    pub from_bus: Vec<usize>,
64    pub to_bus: Vec<usize>,
65    /// Series conductance `r / (r² + x²)` in the selected admittance unit.
66    pub g: Vec<f64>,
67    /// Series susceptance `−x / (r² + x²)` in the selected admittance unit.
68    pub b: Vec<f64>,
69    /// Charging conductance at the from terminal.
70    pub g_fr: Vec<f64>,
71    /// Charging susceptance at the from terminal.
72    pub b_fr: Vec<f64>,
73    /// Charging conductance at the to terminal.
74    pub g_to: Vec<f64>,
75    /// Charging susceptance at the to terminal.
76    pub b_to: Vec<f64>,
77    /// Tap ratio magnitude; one for a line. Kept separate from `shift` so a
78    /// consumer stamps the complex tap itself.
79    pub tap: Vec<f64>,
80    /// Phase shift in radians.
81    pub shift: Vec<f64>,
82    /// Apparent power limit in the selected power unit. Zero means unlimited.
83    pub s_max: Vec<f64>,
84    /// Branch angle bounds in radians, as the source states them.
85    pub angle_min: Vec<f64>,
86    pub angle_max: Vec<f64>,
87    /// Branch column to source branch row.
88    pub source_rows: Vec<usize>,
89    /// Source branch rows omitted because `r² + x² = 0`.
90    pub skipped_zero_impedance: Vec<usize>,
91}
92
93/// Generator data in generator column order.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct AcGeneratorData {
97    /// Generator column to dense bus index.
98    pub bus_of_gen: Vec<usize>,
99    /// Generator column to source generator row.
100    pub source_rows: Vec<usize>,
101    /// Quadratic objective diagonal in `0.5 * q * p^2 + c * p + c0`.
102    pub q: Vec<f64>,
103    /// Linear objective coefficient.
104    pub c: Vec<f64>,
105    /// Constant objective term. Unscaled in both unit systems: it carries no
106    /// power dimension.
107    pub c0: Vec<f64>,
108    pub pmax: Vec<f64>,
109    pub pmin: Vec<f64>,
110    pub qmax: Vec<f64>,
111    pub qmin: Vec<f64>,
112    /// Scheduled active output in the selected power unit.
113    pub pg: Vec<f64>,
114    /// Scheduled reactive output in the selected power unit.
115    pub qg: Vec<f64>,
116    /// Voltage magnitude setpoint, per unit; zero when the source has none.
117    pub vg: Vec<f64>,
118}
119
120/// Generator data in dense bus order, aggregated over the generators at each
121/// bus. See [`AcOpfInstance::nodal_generator_data`].
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct NodalAcGeneratorData {
125    pub q: Vec<f64>,
126    pub c: Vec<f64>,
127    pub c0: Vec<f64>,
128    pub pmax: Vec<f64>,
129    pub pmin: Vec<f64>,
130    pub qmax: Vec<f64>,
131    pub qmin: Vec<f64>,
132    /// Which buses host a generator. A bus without one has a zero range and a
133    /// zero cost, which a formulation must not read as a free generator. A
134    /// reactive limit loop reads it to tell a bus that holds its voltage from
135    /// one that cannot.
136    pub has_gen: Vec<bool>,
137}
138
139/// Matrix free AC OPF input data on the branch pi model.
140///
141/// A problem instance is complete numerical input for one problem family. It
142/// is separate from the source network, a matrix projection, a solver
143/// formulation, and a solution. Relaxations of AC OPF, the SOC forms
144/// included, consume this same instance; the relaxation is a formulation
145/// choice made downstream.
146///
147/// Units follow [`Units`]. Under [`Units::PerUnit`], powers are per unit on
148/// `base_mva` and admittances are per unit on the system base. Under
149/// [`Units::Native`], powers stay in MW/MVAr and every admittance vector is
150/// scaled by `base_mva`, so power computed from admittances and per unit
151/// voltages lands in MW/MVAr. Voltage magnitudes are per unit and angles are
152/// radians in both systems.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[non_exhaustive]
155pub struct AcOpfInstance {
156    pub name: String,
157    pub n_buses: usize,
158    pub n_source_generators: usize,
159    pub n_source_branches: usize,
160    pub base_mva: f64,
161    pub units: Units,
162    pub skip_zero_impedance: bool,
163    /// Dense bus index to external bus ID.
164    pub bus_ids: Vec<BusId>,
165    pub reference_buses: ReferenceBuses,
166    pub buses: AcBusData,
167    pub generators: AcGeneratorData,
168    pub branches: AcBranchData,
169}
170
171impl AcOpfInstance {
172    #[must_use]
173    pub fn n_generators(&self) -> usize {
174        self.generators.q.len()
175    }
176
177    #[must_use]
178    pub fn n_branches(&self) -> usize {
179        self.branches.g.len()
180    }
181
182    /// Project generator cost and bounds to bus space.
183    ///
184    /// The bounds at a bus are the sum of the generator bounds, which is the
185    /// range the bus total can reach. The cost curves at a bus combine by the
186    /// parallel rule `q = 1 / Σ(1/qᵢ)`, the curve that the least cost split of
187    /// the bus total follows. That combination is an approximation: it agrees
188    /// with generator space only while the split stays inside the bound of
189    /// each generator. A bus with one generator keeps that generator's own
190    /// coefficients.
191    #[must_use]
192    pub fn nodal_generator_data(&self) -> NodalAcGeneratorData {
193        let n = self.n_buses;
194        let generators = &self.generators;
195        let bus_of_gen = &generators.bus_of_gen;
196        let costs =
197            nodal::combine_costs(n, bus_of_gen, &generators.q, &generators.c, &generators.c0);
198        NodalAcGeneratorData {
199            q: costs.q,
200            c: costs.c,
201            c0: costs.c0,
202            pmax: nodal::sum_by_bus(n, bus_of_gen, &generators.pmax),
203            pmin: nodal::sum_by_bus(n, bus_of_gen, &generators.pmin),
204            qmax: nodal::sum_by_bus(n, bus_of_gen, &generators.qmax),
205            qmin: nodal::sum_by_bus(n, bus_of_gen, &generators.qmin),
206            has_gen: nodal::buses_with_generators(n, bus_of_gen),
207        }
208    }
209
210    /// Conventional voltage magnitude start: the case voltage, overwritten by
211    /// each generator's positive setpoint in generator column order (last
212    /// wins), with a non-positive case voltage falling back to 1.0.
213    ///
214    /// The result is not clamped to `[vm_min, vm_max]`; feasibility repair is
215    /// solver preparation and stays downstream.
216    #[must_use]
217    pub fn vm_setpoints(&self) -> Vec<f64> {
218        let mut vm: Vec<f64> = self
219            .buses
220            .vm
221            .iter()
222            .map(|&value| if value > 0.0 { value } else { 1.0 })
223            .collect();
224        for generator in 0..self.n_generators() {
225            let vg = self.generators.vg[generator];
226            if vg > 0.0 {
227                vm[self.generators.bus_of_gen[generator]] = vg;
228            }
229        }
230        vm
231    }
232}
233
234/// Build a matrix free AC OPF instance from an indexed network.
235#[allow(clippy::too_many_lines)]
236pub fn build_ac_opf_instance(
237    case: &IndexedNetwork,
238    options: &AcOpfOptions,
239) -> Result<AcOpfInstance> {
240    case.check_reference_coverage()?;
241    case.network().check_base_mva()?;
242
243    let n_buses = case.n();
244    let base = case.per_unit_base();
245    let (p_scale, y_scale) = options.units.power_scales(base);
246    let thermal = limits::ThermalLimits {
247        synthesize_unrated: options.synthesize_unrated_limits,
248        power_scale: p_scale,
249        admittance_scale: y_scale,
250    };
251    let (q_scale, c_scale) = options.units.cost_scales(base);
252
253    let mut bus_of_gen = Vec::new();
254    let mut generator_rows = Vec::new();
255    let mut cost_q = Vec::new();
256    let mut cost_c = Vec::new();
257    let mut cost_c0 = Vec::new();
258    let mut pmax = Vec::new();
259    let mut pmin = Vec::new();
260    let mut qmax = Vec::new();
261    let mut qmin = Vec::new();
262    let mut pg = Vec::new();
263    let mut qg = Vec::new();
264    let mut vg = Vec::new();
265
266    for (source_row, generator) in case.in_service_gens() {
267        let bus = case
268            .bus_index(generator.bus)
269            .ok_or(powerio::Error::UnknownBus {
270                bus_id: generator.bus,
271                element_index: source_row,
272            })?;
273        let cost = generator
274            .cost
275            .as_ref()
276            .ok_or(powerio::Error::MissingGenCost {
277                gen_index: source_row,
278            })?;
279        let (q_raw, c_raw, c0_raw) = nodal::quadratic_terms(cost, source_row)?;
280        bus_of_gen.push(bus);
281        generator_rows.push(source_row);
282        cost_q.push(q_raw * q_scale);
283        cost_c.push(c_raw * c_scale);
284        cost_c0.push(c0_raw);
285        pmax.push(generator.pmax * p_scale);
286        pmin.push(generator.pmin * p_scale);
287        qmax.push(generator.qmax * p_scale);
288        qmin.push(generator.qmin * p_scale);
289        pg.push(generator.pg * p_scale);
290        qg.push(generator.qg * p_scale);
291        vg.push(generator.vg);
292    }
293    if cost_q.is_empty() {
294        return Err(Error::NoGenerators);
295    }
296
297    let mut g_s: Vec<f64> = case.gs().iter().map(|value| value * p_scale).collect();
298    let mut b_s: Vec<f64> = case.bs().iter().map(|value| value * p_scale).collect();
299
300    let mut from_bus = Vec::new();
301    let mut to_bus = Vec::new();
302    let mut g = Vec::new();
303    let mut b = Vec::new();
304    let mut g_fr = Vec::new();
305    let mut b_fr = Vec::new();
306    let mut g_to = Vec::new();
307    let mut b_to = Vec::new();
308    let mut tap = Vec::new();
309    let mut shift = Vec::new();
310    let mut s_max = Vec::new();
311    let mut angle_min = Vec::new();
312    let mut angle_max = Vec::new();
313    let mut branch_rows = Vec::new();
314    let mut skipped_zero_impedance = Vec::new();
315    // Dense bus order is the position order of `network().buses`; the view
316    // already holds the star-lowered network when 3-winding expansion ran.
317    let network = case.network();
318
319    for (source_row, branch) in case.in_service_branches() {
320        let from = case
321            .bus_index(branch.from)
322            .ok_or(powerio::Error::UnknownBus {
323                bus_id: branch.from,
324                element_index: source_row,
325            })?;
326        let to = case
327            .bus_index(branch.to)
328            .ok_or(powerio::Error::UnknownBus {
329                bus_id: branch.to,
330                element_index: source_row,
331            })?;
332        let Some((series_g, series_b)) = branch.series_admittance(source_row)? else {
333            if options.skip_zero_impedance {
334                skipped_zero_impedance.push(source_row);
335                continue;
336            }
337            return Err(powerio::Error::ZeroImpedance { row: source_row }.into());
338        };
339        let charging = branch.terminal_charging();
340        if from == to {
341            // A self-loop is not a flow element; its whole pi model stamp
342            // lands on the bus diagonal, exactly as `build_ybus` folds it.
343            // With t = tap·e^{jθ}: Yff + Yft + Ytf + Ytt
344            //   = (y + y_fr)/tap² + (y + y_to) − y·2cos(θ)/tap.
345            let tap = branch.divisible_tap(source_row)?;
346            let tap_squared = tap * tap;
347            let cross = 2.0 * case.angle_radians(branch.shift).cos() / tap;
348            g_s[from] += ((series_g + charging.g_fr) / tap_squared + (series_g + charging.g_to)
349                - series_g * cross)
350                * y_scale;
351            b_s[from] += ((series_b + charging.b_fr) / tap_squared + (series_b + charging.b_to)
352                - series_b * cross)
353                * y_scale;
354            continue;
355        }
356        from_bus.push(from);
357        to_bus.push(to);
358        g.push(series_g * y_scale);
359        b.push(series_b * y_scale);
360        g_fr.push(charging.g_fr * y_scale);
361        b_fr.push(charging.b_fr * y_scale);
362        g_to.push(charging.g_to * y_scale);
363        b_to.push(charging.b_to * y_scale);
364        let amin = case.angle_radians(branch.angmin);
365        let amax = case.angle_radians(branch.angmax);
366        tap.push(branch.divisible_tap(source_row)?);
367        shift.push(case.angle_radians(branch.shift));
368        s_max.push(thermal.of(
369            branch,
370            amin,
371            amax,
372            network.buses[from].vmax,
373            network.buses[to].vmax,
374        ));
375        angle_min.push(amin);
376        angle_max.push(amax);
377        branch_rows.push(source_row);
378    }
379
380    let mut vm_min = Vec::with_capacity(n_buses);
381    let mut vm_max = Vec::with_capacity(n_buses);
382    let mut vm = Vec::with_capacity(n_buses);
383    for bus in &network.buses {
384        vm_min.push(bus.vmin);
385        vm_max.push(bus.vmax);
386        vm.push(bus.vm);
387    }
388
389    Ok(AcOpfInstance {
390        name: case.name().to_owned(),
391        n_buses,
392        n_source_generators: case.generators().len(),
393        n_source_branches: case.branches().len(),
394        base_mva: case.base_mva(),
395        units: options.units,
396        skip_zero_impedance: options.skip_zero_impedance,
397        bus_ids: (0..n_buses).map(|index| case.bus_id(index)).collect(),
398        reference_buses: ReferenceBuses::new(case.reference_bus_indices()),
399        buses: AcBusData {
400            p_d: case.pd().iter().map(|value| value * p_scale).collect(),
401            q_d: case.qd().iter().map(|value| value * p_scale).collect(),
402            g_s,
403            b_s,
404            vm_min,
405            vm_max,
406            vm,
407        },
408        generators: AcGeneratorData {
409            bus_of_gen,
410            source_rows: generator_rows,
411            q: cost_q,
412            c: cost_c,
413            c0: cost_c0,
414            pmax,
415            pmin,
416            qmax,
417            qmin,
418            pg,
419            qg,
420            vg,
421        },
422        branches: AcBranchData {
423            from_bus,
424            to_bus,
425            g,
426            b,
427            g_fr,
428            b_fr,
429            g_to,
430            b_to,
431            tap,
432            shift,
433            s_max,
434            angle_min,
435            angle_max,
436            source_rows: branch_rows,
437            skipped_zero_impedance,
438        },
439    })
440}