Skip to main content

powerio_matrix/
ac_jacobian.rs

1//! The sparse AC power flow Jacobian at an operating point (#407).
2//!
3//! [`calc_power_flow_jacobian`] returns the full physical derivative of every
4//! bus active and reactive power injection with respect to every voltage
5//! coordinate, `2n × 2n`, over an [`AcPfInstance`] plus a separately supplied
6//! complete [`OperatingPoint`] sharing the same network identities. It does
7//! not replace columns with generator injection variables, remove fixed
8//! variables, or add voltage setpoint equations: those belong to the solver
9//! that selects its equations and variable arrangement.
10//!
11//! Row `k` is bus `k`'s active power and row `n + k` its reactive power, in
12//! bus table order. Under polar coordinates column `m` is bus `m`'s voltage
13//! angle (radians) and column `n + m` its voltage magnitude (per unit); under
14//! Cartesian coordinates the columns are the real then imaginary voltage. All
15//! powers are per unit on the network MVA base.
16//!
17//! The sparse structure is allocated once from the admittance matrix pattern;
18//! [`PowerFlowJacobian::update`] refreshes the numerical values in place
19//! across operating points with unchanged topology.
20
21use crate::{IndexCore, IndexedNetwork, SparseMatrix};
22use powerio_core::Error;
23use powerio_tx::BusId;
24
25use powerio_prob::diagnostics::codes;
26use powerio_prob::{AcPfInstance, BalancedOperatingPointQuantity, OperatingPoint};
27
28/// The voltage coordinate selection, the one option of the calculation.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum VoltageCoordinates {
32    /// Angle (radians) then magnitude (per unit) columns.
33    #[default]
34    Polar,
35    /// Real then imaginary voltage columns, both per unit.
36    Cartesian,
37}
38
39/// The assembled sparse physical Jacobian, with its bus mappings and the
40/// machinery to update values in place.
41#[derive(Clone, Debug)]
42pub struct PowerFlowJacobian {
43    coordinates: VoltageCoordinates,
44    bus_ids: Vec<BusId>,
45    matrix: SparseMatrix,
46    /// The admittance parts the values derive from, built once.
47    conductance: SparseMatrix,
48    susceptance: SparseMatrix,
49    /// The instance network's derived index, cached so
50    /// [`update`](Self::update) rebinds a view over a fresh instance with
51    /// [`IndexedNetwork::with_core`] instead of re-deriving it on every
52    /// refresh.
53    index_core: IndexCore,
54}
55
56impl PowerFlowJacobian {
57    /// The `2n × 2n` sparse matrix. Row `k` is active power at bus `k`, row
58    /// `n + k` reactive power; the columns follow
59    /// [`coordinates`](Self::coordinates).
60    #[must_use]
61    pub const fn matrix(&self) -> &SparseMatrix {
62        &self.matrix
63    }
64
65    /// The bus behind row and column block position `k`, for both blocks of
66    /// both dimensions.
67    #[must_use]
68    pub fn bus_ids(&self) -> &[BusId] {
69        &self.bus_ids
70    }
71
72    /// The selected voltage coordinates.
73    #[must_use]
74    pub const fn coordinates(&self) -> VoltageCoordinates {
75        self.coordinates
76    }
77
78    /// Refresh the numerical values at a new operating point. The sparse
79    /// structure and the admittance parts are reused; topology changes
80    /// require a rebuild through [`calc_power_flow_jacobian`].
81    ///
82    /// # Errors
83    /// An operating point that does not share the instance's network
84    /// identities or does not state both voltage quantities.
85    pub fn update(
86        &mut self,
87        instance: &AcPfInstance,
88        point: &OperatingPoint<powerio_tx::BalancedNetwork>,
89    ) -> Result<(), Error> {
90        // `with_core` rebinds a view over the cached derivation with no
91        // allocation when the instance's network has no 3-winding
92        // transformer (the common case); it only re-derives the core when
93        // one is present, matching `new`'s behavior exactly either way.
94        let view = IndexedNetwork::with_core(instance.network(), &self.index_core);
95        // The refresh operates on the axis the structure was assembled over;
96        // an instance lowering to a different bus count is a different
97        // problem, never a value update.
98        if view.n() != self.bus_ids.len() {
99            return Err(Error::new(
100                &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
101                "the instance's lowered bus axis does not match the assembled Jacobian's",
102            ));
103        }
104        // Identity pairing between an instance and a point is validated
105        // with a real error the one time `calc_power_flow_jacobian` built
106        // this Jacobian; every refresh after that trusts the same pairing
107        // and only asserts it in debug builds.
108        let voltages = point_voltages(instance, point, &self.bus_ids, &view, false)?;
109        fill_values(
110            &mut self.matrix,
111            &self.conductance,
112            &self.susceptance,
113            &voltages,
114            self.coordinates,
115        );
116        Ok(())
117    }
118}
119
120/// One complete complex voltage assignment, split for the fill.
121struct Voltages {
122    magnitude: Vec<f64>,
123    angle: Vec<f64>,
124}
125
126/// Compute the sparse physical AC power flow Jacobian for `instance` at
127/// `point`.
128///
129/// # Errors
130/// An operating point that does not share the instance's network identities
131/// or does not state bus voltage magnitudes and angles; an admittance build
132/// failure (a zero impedance branch is refused, never skipped).
133pub fn calc_power_flow_jacobian(
134    instance: &AcPfInstance,
135    point: &OperatingPoint<powerio_tx::BalancedNetwork>,
136    coordinates: VoltageCoordinates,
137) -> Result<PowerFlowJacobian, Error> {
138    let network = instance.network();
139    // Derived once here and cached on the returned Jacobian, so
140    // `update` rebinds a view over it with `IndexedNetwork::with_core`
141    // instead of re-deriving it on every refresh.
142    let index_core = IndexCore::build(network);
143    // The analysis axis comes from the same lowered view the admittance is
144    // built from, so a three winding expansion's star buses are part of the
145    // pattern and the assembled dimension always equals the admittance
146    // dimension.
147    let view = IndexedNetwork::with_core(network, &index_core);
148    let bus_ids: Vec<BusId> = (0..view.n()).map(|idx| view.bus_id(idx)).collect();
149    // The one point this pairing is validated with a real error; every
150    // later `update` call trusts it instead (see `point_voltages`).
151    let voltages = point_voltages(instance, point, &bus_ids, &view, true)?;
152
153    let parts = crate::calc_admittance_matrix(
154        &view,
155        &crate::BuildOptions {
156            skip_zero_impedance: false,
157            ..Default::default()
158        },
159    )
160    .map_err(|error| {
161        Error::new(
162            &codes::BUILD_OPERATOR_ZERO_IMPEDANCE,
163            format!(
164                "the admittance matrix the Jacobian derives from cannot be built: {error}; resolve zero impedance branches explicitly with merge_zero_impedance_buses"
165            ),
166        )
167    })?;
168
169    let n = bus_ids.len();
170    // The structure comes from the admittance pattern once: each admittance
171    // entry contributes to all four blocks, and every diagonal position is
172    // present for the current terms.
173    let mut pattern = crate::matrix::triplet::CooBuilder::new(2 * n);
174    for (row, row_vec) in parts.g.outer_iterator().enumerate() {
175        for (column, _) in row_vec.iter() {
176            pattern.add(row, column, 1.0);
177            pattern.add(row, n + column, 1.0);
178            pattern.add(n + row, column, 1.0);
179            pattern.add(n + row, n + column, 1.0);
180        }
181    }
182    for (row, row_vec) in parts.b.outer_iterator().enumerate() {
183        for (column, _) in row_vec.iter() {
184            pattern.add(row, column, 1.0);
185            pattern.add(row, n + column, 1.0);
186            pattern.add(n + row, column, 1.0);
187            pattern.add(n + row, n + column, 1.0);
188        }
189    }
190    for row in 0..n {
191        pattern.add(row, row, 1.0);
192        pattern.add(row, n + row, 1.0);
193        pattern.add(n + row, row, 1.0);
194        pattern.add(n + row, n + row, 1.0);
195    }
196
197    let mut jacobian = PowerFlowJacobian {
198        coordinates,
199        bus_ids,
200        matrix: pattern.finish_csr(),
201        conductance: parts.g,
202        susceptance: parts.b,
203        index_core,
204    };
205    fill_values(
206        &mut jacobian.matrix,
207        &jacobian.conductance,
208        &jacobian.susceptance,
209        &voltages,
210        coordinates,
211    );
212    Ok(jacobian)
213}
214
215/// The complete voltage assignment from the operating point, in bus row
216/// order, with the identity checks #407 requires.
217///
218/// `validate_identities` runs the network pairing check with a real error on
219/// mismatch — [`calc_power_flow_jacobian`] passes `true` the one time it
220/// builds a Jacobian. [`PowerFlowJacobian::update`] passes `false`: every
221/// refresh reuses the pairing that build already proved, so re-checking it
222/// in full on each call would cost as much as the rebuild `update` exists to
223/// avoid; the same check still runs as a `debug_assert` to catch a
224/// genuine misuse in tests.
225fn point_voltages(
226    instance: &AcPfInstance,
227    point: &OperatingPoint<powerio_tx::BalancedNetwork>,
228    bus_ids: &[BusId],
229    view: &IndexedNetwork<'_>,
230    validate_identities: bool,
231) -> Result<Voltages, Error> {
232    // The point must share the instance's own bus identity list exactly, in
233    // the same order; the analysis axis may extend it with the expansion's
234    // star buses, whose voltages come from the lowered network below. Every
235    // bus quantity's column order is `network.buses()` order (how
236    // `BalancedOperatingPointBuilder` lays the columns out), so this reads
237    // the typed quantity iterators rather than allocating a decimal spelling
238    // of every bus id to look one up at a time.
239    let raw_buses = instance.network().buses();
240    let read_bus_values = |values: powerio_prob::OperatingPointValues<'_>| {
241        if values.len() != raw_buses.len() {
242            return Err(Error::new(
243                &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
244                "the operating point's network does not share the instance's bus identities",
245            ));
246        }
247        let identities_match = values
248            .clone()
249            .zip(raw_buses)
250            .all(|((id, _), bus)| id.parse::<usize>().is_ok_and(|id| id == bus.id.0));
251        if validate_identities && !identities_match {
252            return Err(Error::new(
253                &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
254                "the operating point's network does not share the instance's bus identities",
255            ));
256        } else if !validate_identities {
257            debug_assert!(
258                identities_match,
259                "operating point bus identities do not match the instance's; this pairing \
260                 is checked once when the Jacobian is built and trusted on every refresh"
261            );
262        }
263        Ok(values.map(|(_, value)| value).collect::<Vec<_>>())
264    };
265
266    // Both quantities' complete columns, one bulk read each rather than one
267    // string keyed lookup per bus per quantity.
268    let (Some(magnitude_values), Some(angle_values)) = (
269        point.values(BalancedOperatingPointQuantity::BusVoltageMagnitude),
270        point.values(BalancedOperatingPointQuantity::BusVoltageAngle),
271    ) else {
272        return Err(Error::new(
273            &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
274            "the operating point does not state a complete complex voltage at every bus; \
275             the Jacobian needs both quantities",
276        ));
277    };
278
279    let raw_n = raw_buses.len();
280    let mut magnitude = read_bus_values(magnitude_values)?;
281    let mut angle = read_bus_values(angle_values)?;
282    magnitude.reserve(bus_ids.len() - raw_n);
283    angle.reserve(bus_ids.len() - raw_n);
284    for (idx, &bus) in bus_ids.iter().enumerate().skip(raw_n) {
285        // A star bus the three winding expansion synthesized: the point
286        // cannot state it, so its voltage comes from the lowered network's
287        // own stated values, through a checked lookup.
288        let star = view
289            .network()
290            .buses()
291            .get(idx)
292            .filter(|star| star.id == bus);
293        let Some(star) = star else {
294            return Err(Error::new(
295                &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
296                "the operating point's network does not share the instance's bus identities",
297            ));
298        };
299        magnitude.push(star.vm);
300        angle.push(view.to_radians(star.va));
301    }
302    Ok(Voltages { magnitude, angle })
303}
304
305/// Fill every value of the assembled structure at the given voltages. The
306/// fill walks the fixed sparse structure once: each entry's value comes from
307/// its `(bus row, bus column, block)` position and the merged admittance row,
308/// so the cost follows the nonzero count, never the dense square.
309#[allow(clippy::many_single_char_names)] // k/m/n and G/B are the textbook notation
310#[allow(clippy::too_many_lines)] // the four blocks of both coordinate systems, stated in full
311#[allow(clippy::match_same_arms)] // block positions coincide numerically, never semantically
312fn fill_values(
313    matrix: &mut SparseMatrix,
314    conductance: &SparseMatrix,
315    susceptance: &SparseMatrix,
316    voltages: &Voltages,
317    coordinates: VoltageCoordinates,
318) {
319    let n = voltages.magnitude.len();
320    let vm = &voltages.magnitude;
321    let va = &voltages.angle;
322
323    // Merged sparse admittance rows: sorted `(m, g, b)` per bus row.
324    let mut admittance_rows: Vec<Vec<(usize, f64, f64)>> = vec![Vec::new(); n];
325    for (row, row_vec) in conductance.outer_iterator().enumerate() {
326        for (column, &g) in row_vec.iter() {
327            admittance_rows[row].push((column, g, 0.0));
328        }
329    }
330    for (row, row_vec) in susceptance.outer_iterator().enumerate() {
331        for (column, &b) in row_vec.iter() {
332            match admittance_rows[row].binary_search_by_key(&column, |entry| entry.0) {
333                Ok(position) => admittance_rows[row][position].2 = b,
334                Err(position) => admittance_rows[row].insert(position, (column, 0.0, b)),
335            }
336        }
337    }
338    let admittance_at = |k: usize, m: usize| -> (f64, f64) {
339        match admittance_rows[k].binary_search_by_key(&m, |entry| entry.0) {
340            Ok(position) => {
341                let (_, g, b) = admittance_rows[k][position];
342                (g, b)
343            }
344            Err(_) => (0.0, 0.0),
345        }
346    };
347
348    // Current injection I = Y V and complex power S = diag(V) conj(I), for
349    // the diagonal terms.
350    let mut current_re = vec![0.0; n];
351    let mut current_im = vec![0.0; n];
352    for (k, row) in admittance_rows.iter().enumerate() {
353        let mut ir = 0.0;
354        let mut ii = 0.0;
355        for &(m, g, b) in row {
356            let (sin, cos) = va[m].sin_cos();
357            let vr = vm[m] * cos;
358            let vi = vm[m] * sin;
359            ir += g * vr - b * vi;
360            ii += g * vi + b * vr;
361        }
362        current_re[k] = ir;
363        current_im[k] = ii;
364    }
365    let p: Vec<f64> = (0..n)
366        .map(|k| vm[k] * va[k].cos() * current_re[k] + vm[k] * va[k].sin() * current_im[k])
367        .collect();
368    let q: Vec<f64> = (0..n)
369        .map(|k| vm[k] * va[k].sin() * current_re[k] - vm[k] * va[k].cos() * current_im[k])
370        .collect();
371
372    for (row, mut row_vec) in matrix.outer_iterator_mut().enumerate() {
373        let k = row % n;
374        let reactive_row = row >= n;
375        for (column, value) in row_vec.iter_mut() {
376            let m = column % n;
377            let magnitude_column = column >= n;
378            let (g, b) = admittance_at(k, m);
379            *value = match coordinates {
380                VoltageCoordinates::Polar => {
381                    if k == m {
382                        // MATPOWER dSbus_dV diagonals.
383                        match (reactive_row, magnitude_column) {
384                            (false, false) => -q[k] - b * vm[k] * vm[k],
385                            (true, false) => p[k] - g * vm[k] * vm[k],
386                            (false, true) => {
387                                let over = if vm[k] == 0.0 { 0.0 } else { p[k] / vm[k] };
388                                over + g * vm[k]
389                            }
390                            (true, true) => {
391                                let over = if vm[k] == 0.0 { 0.0 } else { q[k] / vm[k] };
392                                over - b * vm[k]
393                            }
394                        }
395                    } else {
396                        let (sin, cos) = (va[k] - va[m]).sin_cos();
397                        let odd = g * sin - b * cos;
398                        let even = g * cos + b * sin;
399                        match (reactive_row, magnitude_column) {
400                            (false, false) => vm[k] * vm[m] * odd,
401                            (true, false) => -vm[k] * vm[m] * even,
402                            (false, true) => vm[k] * even,
403                            (true, true) => vm[k] * odd,
404                        }
405                    }
406                }
407                VoltageCoordinates::Cartesian => {
408                    // dS_k/dVr_m = δ conj(I_k) + V_k conj(Y_km);
409                    // dS_k/dVi_m = jδ conj(I_k) − j V_k conj(Y_km).
410                    let vr_k = vm[k] * va[k].cos();
411                    let vi_k = vm[k] * va[k].sin();
412                    let real_part = vr_k * g + vi_k * b;
413                    let imag_part = vi_k * g - vr_k * b;
414                    let mut entry = match (reactive_row, magnitude_column) {
415                        (false, false) => real_part,
416                        (true, false) => imag_part,
417                        (false, true) => imag_part,
418                        (true, true) => -real_part,
419                    };
420                    if k == m {
421                        entry += match (reactive_row, magnitude_column) {
422                            (false, false) => current_re[k],
423                            (true, false) => -current_im[k],
424                            (false, true) => current_im[k],
425                            (true, true) => current_re[k],
426                        };
427                    }
428                    entry
429                }
430            };
431        }
432    }
433}